diff --git a/.dockerignore b/.dockerignore
index f90d979..4b9969e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,36 +1,39 @@
-# .dockerignore: Исключения для Docker сборки
-# Игнорировать всё, кроме необходимого для production
+# Исключить скрытые системные каталоги, но не всё подряд
+.git/
+.gitattributes
+.gitignore
-**/.git
-**/.gitignore
-**/.dockerignore
-**/Dockerfile
-**/README.md
+# Виртуальные окружения и Python-кэш
+.venv/
+__pycache__/
+*.py[cod]
+*.pyo
-# Директории
-**/__pycache__
-**/.mypy_cache
-**/.pytest_cache
-**/.idea
-**/.vscode
-**/test
-**/tests
-**/docs
-**/examples
+# IDE-файлы
+.idea/
+.vscode/
-# Файлы
-**/*.pyc
-**/*.pyo
-**/*.pyd
-**/*.egg-info
-**/*.log
-**/*.logs
-**/*.sqlite
-**/*.db
-config/.env
-**/docker-compose*
+# Тесты и документация
+tests/
+test/
+docs/
+examples/
-# Артефакты сборки
-**/build
-**/dist
-**/node_modules
+# Логи и артефакты сборки
+*.log
+*.logs
+Logs/
+Log/
+dist/
+build/
+
+# Примеры и шаблоны
+env_example
+.env
+
+# Опционально (если не нужны в образе):
+docker-compose.yml
+poetry.lock
+pyproject.toml
+README.md
+LICENSE
diff --git a/.gitignore b/.gitignore
index 5c7d07d..0e877d1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,8 +3,8 @@
### Python ###
# Виртуальные окружения и настройки
-config/.env
-../../../../Desktop/PostBot/.venv
+configs/.env
+.venv
venv/
env/
ENV/
@@ -65,3 +65,4 @@ htmlcov/
.nox/
.pytest_cache/
.mypy_cache/
+/.env
diff --git a/.idea/PRIMOSTORYFINAL.iml b/.idea/PRIMOSTORYFINAL.iml
index 0685815..93cd0ef 100644
--- a/.idea/PRIMOSTORYFINAL.iml
+++ b/.idea/PRIMOSTORYFINAL.iml
@@ -6,9 +6,19 @@
-
Зачёркнутый
-Моноширинный
-
Предварительно отформатированный-Ссылка -""" - ), - reply_markup=cancel_button(), parse_mode=None - ) - -@router.message(PostState.waiting_for_text) -async def got_text(message: Message, state: FSMContext): - await state.update_data(text=message.text or message.caption or "") - await state.set_state(PostState.waiting_for_privacy) - data = await state.get_data() - await message.reply( - "Выберите приватность поста:", - reply_markup=privacy_markup(data.get('private', False)) - ) - -@router.callback_query(lambda c: c.data == "toggle_privacy") -async def toggle_privacy(cq: CallbackQuery, state: FSMContext): - data = await state.get_data() - is_priv = not data.get('private', False) - await state.update_data(private=is_priv) - await cq.message.edit_reply_markup( - reply_markup=privacy_markup(is_priv) - ) - await cq.answer() - -@router.callback_query(lambda c: c.data == "continue_creation") -async def continue_to_id(cq: CallbackQuery, state: FSMContext): - await state.set_state(PostState.waiting_for_id) - await cq.message.edit_text("Введите уникальный ID поста (латиница, цифры, подчёрки):") - await cq.answer() - -@router.message(PostState.waiting_for_id) -async def got_id(message: Message, state: FSMContext): - pid = message.text.strip() - if not pid.replace('_', '').isalnum(): - await message.reply( - "ID должен содержать только латиницу, цифры и подчёркивания.", - reply_markup=cancel_button() - ) - return - - with post_id_lock: - if not storage.is_post_available(pid): - await message.reply( - text="Этот ID уже занят, введите другой:", - reply_markup=cancel_button() - ) - return - - await state.update_data(post_id=pid) - await state.set_state(PostState.waiting_for_image) - await message.reply( - text="Отправьте ссылку на изображение или 'нет':\n" - "Пример: https://img4.teletype.in/files/f2/47/...", - reply_markup=cancel_button() - ) - -@router.message(PostState.waiting_for_image) -async def got_image(message: Message, state: FSMContext): - img = message.text.strip() - if img.lower() in ('нет', 'no', 'none'): - img = '' - await state.update_data(image=img) - await state.set_state(PostState.waiting_for_buttons) - await message.reply( - textmd2( - """Отправьте кнопки по шаблону: -Кнопка заглушка | void -Уведомление | notification:Для вас! -Кнопка ссылка | https://google.com -Копирование | copy:Копирование текста! -Для одного | callback_data | allowed_ids=123 | unauthorized_message=Нет доступа - -Пустая строка — новый ряд. /done — закончить.""" - ), - reply_markup=cancel_button(), parse_mode=None - ) - -@router.message(PostState.waiting_for_buttons) -async def got_buttons(message: Message, state: FSMContext): - text = message.text.strip() - data = await state.get_data() - uid = message.from_user.id - pid = data['post_id'] - try: - if text.lower() in ('/done', 'none'): - btns = data.get('buttons', []) if text == '/done' else [] - posts = storage.load_user_posts(uid) - posts[pid] = { - 'user_id': uid, - 'text': data['text'], - 'image': data['image'], - 'buttons': btns, - 'private': data.get('private', False) - } - storage.save_user_posts(uid, posts) - await message.reply( - f"✅ Пост создан! ID: {pid}\n" - f"{'🔒 Приватный' if data.get('private') else '🔓 Публичный'}\n" - f"Используйте:
@{(await message.bot.me()).username} {pid}"
- )
- await state.clear()
- return
-
- rows = parse_buttons(text)
- existing = data.get('buttons', [])
- await state.update_data(buttons=existing + rows)
- await message.reply(
- text="✅ Кнопки добавлены. Добавьте ещё или /done для окончания.",
- reply_markup=cancel_button()
- )
- except ValueError as err:
- await message.reply(f"❌ {err}")
-
-@router.callback_query(lambda c: c.data == "cancel_creation")
-async def cancel(cq: CallbackQuery, state: FSMContext):
- await state.clear()
- await cq.message.reply(textmd2("Процесс создания поста отменён."))
- await cq.answer()
diff --git a/BotCode/utils/pagination.py b/BotCode/utils/pagination.py
deleted file mode 100644
index c139cb0..0000000
--- a/BotCode/utils/pagination.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# BotCode/utils/pagination.py
-from typing import List
-from aiogram.types import InlineKeyboardButton
-
-# Настройка экспорта в модули
-__all__ = ('create_pagination_buttons',)
-
-def create_pagination_buttons(action: str,
- page: int = 0,
- total_posts: int = 0,
- bt_page: int = 5) -> List[InlineKeyboardButton]:
- """Создает кнопки для пагинации."""
- navigation_buttons = []
- if page > 0:
- navigation_buttons.append(InlineKeyboardButton(
- text="←", callback_data=f"{action}_page_{page - 1}"
- ))
- if (page + 1) * bt_page < total_posts:
- navigation_buttons.append(InlineKeyboardButton(
- text="→", callback_data=f"{action}_page_{page + 1}"
- ))
- return navigation_buttons
diff --git a/Dockerfile b/Dockerfile
index cc7d007..566dcd6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,23 @@
-FROM mwalbeck/python-poetry:2.1-3.11
+# Используем официальный облегчённый образ Python 3.11
+FROM python:3.11-slim
-WORKDIR /PostBot
+# Задаём рабочую директорию внутри контейнера
+WORKDIR /app
-COPY pyproject.toml poetry.lock ./
-RUN poetry install --no-interaction --no-root --only main
+# Обновляем pip для актуальной версии (необязательно, но рекомендуется)
+RUN pip install --upgrade pip
-COPY ../../../../Desktop/PostBot .
+# Копируем файл зависимостей в контейнер
+COPY requirements.txt .
-CMD ["poetry", "run", "python", "-m", "main"]
+# Устанавливаем зависимости из requirements.txt
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Копируем весь код проекта в рабочую директорию контейнера
+COPY . .
+
+# Опционально: задаём переменную окружения для оптимизации работы Python
+ENV PYTHONUNBUFFERED=1
+
+# Указываем команду запуска бота (можно изменить под ваш файл)
+CMD ["python", "main.py"]
diff --git a/LICENSE b/LICENSE
index 46329e3..889866f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,21 @@
MIT License
-Copyright (c) [2025] [Лейн]
+Copyright (c) [2025] [Verum]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do so,
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
-The above copyright notice and this permission notice shall be included
+The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
-THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
-OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
-ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
index 7da29b9..544bdb9 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,47 @@
-# Создание поста
-new_post = {
- "id": "cat_post",
- "author_id": 123,
- "mod": "HTML",
- "type": "photo",
- "text": "Мой котик!",
- "media": "cat.jpg",
- "private": True,
- "allowed_users": [456, 789],
- "buttons": [[{
- "type": "share",
- "name": "Поделиться",
- "params": {"message": "Посмотрите этого котика!"}
- }]]
-}
-
-post_id = storage.create_post(new_post)
-
-# Получение поста
-post = storage.get_post(post_id, user_id=456) # Доступ разрешен
-post = storage.get_post(post_id, user_id=000) # Доступ запрещен
-
-# Поиск постов
-results = storage.search_posts("котик", user_id=456)
-
-# Обновление поста
-storage.update_post(post_id, updater_id=123, updates={"text": "Новый текст"})
-
-# Удаление поста
-storage.delete_post(post_id, deleter_id=123)
\ No newline at end of file
+PROJECT/
+├── config/
+│ ├── __init__.py
+│ ├── settings.py # Основные настройки
+│ └── roles_config.py # Конфиг ролей и прав
+├── data/
+│ ├── database.db # SQLite база (или папка для миграций если PostgreSQL)
+│ ├── lists/ # JSON/CSV файлы списков (игроков, персонажей и т.д.)
+│ └── templates/ # Шаблоны сообщений
+├── handlers/
+│ ├── __init__.py
+│ ├── private/ # Обработчики ЛС
+│ │ ├── commands.py
+│ │ ├── faq.py
+│ │ ├── reports.py
+│ │ └── notifications.py
+│ ├── groups/ # Обработчики групповых чатов
+│ │ ├── flood.py
+│ │ ├── roleplay.py
+│ │ └── moderation.py
+│ └── channels/ # Обработчики каналов
+│ ├── info_updater.py
+│ └── life_news.py
+├── middlewares/
+│ ├── __init__.py
+│ ├── throttling.py # Анти-спам
+│ ├── database.py # Интеграция БД
+│ └── mode_switcher.py # Переключение режимов
+├── services/
+│ ├── __init__.py
+│ ├── database.py # CRUD операции
+│ ├── stats.py # Статистика сообщений
+│ ├── list_manager.py # Управление списками
+│ ├── notifier.py # Уведомления
+│ └── antispam.py # Система спам-фильтрации
+├── utils/
+│ ├── __init__.py
+│ ├── parsers.py # Парсинг сообщений
+│ ├── keyboards.py # Генерация клавиатур
+│ └── helpers.py # Вспомогательные функции
+├── states/ # FSM состояния
+│ ├── __init__.py
+│ ├── user_registration.py
+│ └── report_states.py
+├── .env # Переменные окружения
+├── requirements.txt # Зависимости
+└── main.py # Точка входа
\ No newline at end of file
diff --git a/assets/start.jpg b/assets/start.jpg
index 41fea9f..2fb9564 100644
Binary files a/assets/start.jpg and b/assets/start.jpg differ
diff --git a/BotCode/__init__.py b/bot/__init__.py
similarity index 50%
rename from BotCode/__init__.py
rename to bot/__init__.py
index 8e4a6d3..b22fc85 100644
--- a/BotCode/__init__.py
+++ b/bot/__init__.py
@@ -1,4 +1,3 @@
-from .config import *
from .handlers import *
from .utils import *
-from .config import *
+from .bots import *
diff --git a/bot/bots.py b/bot/bots.py
new file mode 100644
index 0000000..ca08330
--- /dev/null
+++ b/bot/bots.py
@@ -0,0 +1,203 @@
+from aiogram import Bot, Dispatcher
+from aiogram.client.default import DefaultBotProperties
+from aiogram.fsm.storage.memory import MemoryStorage
+from aiogram.types import User, ChatAdministratorRights, BotDescription, BotShortDescription
+from aiogram.utils.i18n import ConstI18nMiddleware, I18n
+
+from loggers.logs import loggers
+from configs.config import BotSettings, BotEdit, Webhook
+from middleware.loggers import log
+
+# Экспортируем объекты модуля
+__all__ = ("dp", "bot", "BotInfo", "i18n",)
+
+# Инициализация i18n
+i18n: I18n = I18n(path="locales", default_locale="ru", domain="bot")
+
+# Диспетчер бота, языковых настроек и его хранилища
+storage: MemoryStorage = MemoryStorage()
+dp: Dispatcher = Dispatcher(storage=storage)
+dp.message.outer_middleware(ConstI18nMiddleware(locale='ru', i18n=i18n))
+dp["is_active"]: bool = True
+
+# Экземпляр бота с настройками по умолчанию
+bot: Bot = Bot(token=BotSettings.BOT_TOKEN,
+ default=DefaultBotProperties(
+ parse_mode=BotSettings.PARSE_MODE,
+ disable_notification=BotSettings.DISABLE_NOTIFICATION,
+ protect_content=BotSettings.PROTECT_CONTENT,
+ allow_sending_without_reply=BotSettings.ALLOW_SENDING_WITHOUT_REPLY,
+ link_preview_is_disabled=BotSettings.LINK_PREVIEW_IS_DISABLED,
+ link_preview_prefer_small_media=BotSettings.LINK_PREVIEW_PREFER_SMALL_MEDIA,
+ link_preview_prefer_large_media=BotSettings.LINK_PREVIEW_PREFER_LARGE_MEDIA,
+ link_preview_show_above_text=BotSettings.LINK_PREVIEW_SHOW_ABOVE_TEXT,
+ show_caption_above_media=BotSettings.SHOW_CAPTION_ABOVE_MEDIA
+ )
+)
+
+
+class BotInfo:
+ """Класс для хранения и инициализации данных бота."""
+ id: int = None
+ url: str = None
+ first_name: str = None
+ last_name: str = None
+ username: str = None
+ description: str = None
+ short_description: str = None
+ language_code: str = BotSettings.BOT_LANGUAGE
+ prefix: str = BotSettings.PREFIX
+ bot_owner: str = BotSettings.OWNER
+ added_to_attachment_menu: bool = False
+ supports_inline_queries: bool = False
+ can_connect_to_business: bool = False
+ has_main_web_app: bool = False
+ can_join_groups: bool = False
+ can_read_all_group_messages: bool = False
+
+
+ @classmethod
+ @log(level='INFO', log_type='BOT', text='Настройка вебхука бота')
+ async def webhook(cls, bots: Bot = bot, delete_webhook: bool = Webhook.WEBHOOK) -> None:
+ """
+ Удаление или установка вебхука.
+
+ :param bots: Объект бота для управления.
+ :param delete_webhook: Статус удаления, поумолчанию (true).
+ """
+ if delete_webhook:
+ await bots.delete_webhook()
+
+
+ @classmethod
+ @log(level='INFO', log_type='BOT', text='Получение информации о боте')
+ async def info(cls, bots: Bot = bot) -> dict:
+ """
+ Получает и сохраняет информацию о боте.
+
+ :param bots: Объект бота для управления.
+ :return: Словарь с персональными данными о боте.
+ """
+ bot_info: User = await bots.get_me()
+
+ cls.id = bot_info.id
+ cls.url = f'tg://user?id={cls.id}'
+ cls.first_name = bot_info.first_name
+ cls.last_name = bot_info.last_name
+ cls.username = bot_info.username
+ cls.description = getattr(bot_info, 'description', '')
+ cls.short_description = getattr(bot_info, 'short_description', '')
+ cls.language_code = bot_info.language_code
+ cls.is_premium = bot_info.is_premium
+ cls.added_to_attachment_menu = bot_info.added_to_attachment_menu
+ cls.supports_inline_queries = bot_info.supports_inline_queries
+ cls.can_connect_to_business = bot_info.can_connect_to_business
+ cls.has_main_web_app = bot_info.has_main_web_app
+ cls.can_join_groups = getattr(bot_info, 'can_join_groups', False)
+ cls.can_read_all_group_messages = getattr(bot_info, 'can_read_all_group_messages', False)
+
+ return {
+ 'id': cls.id,
+ 'url': cls.url,
+ 'first_name': cls.first_name,
+ 'last_name': cls.last_name,
+ 'username': cls.username,
+ 'description': cls.description,
+ 'short_description': cls.short_description,
+ 'language_code': cls.language_code,
+ 'prefix': cls.prefix,
+ 'bot_owner': cls.bot_owner,
+ 'is_premium': cls.is_premium,
+ 'added_to_attachment_menu': cls.added_to_attachment_menu,
+ 'supports_inline_queries': cls.supports_inline_queries,
+ 'can_connect_to_business': cls.can_connect_to_business,
+ 'has_main_web_app': cls.has_main_web_app,
+ 'can_join_groups': cls.can_join_groups,
+ 'can_read_all_group_messages': cls.can_read_all_group_messages,
+ }
+
+
+ @staticmethod
+ @log(level='INFO', log_type='BOT', text='Установка прав администратора')
+ async def set_administrator_rights(bots: Bot = bot, rights: ChatAdministratorRights = BotEdit.RIGHTS) -> None:
+ """
+ Устанавливает права администратора по умолчанию.
+
+ :param bots: Объект бота для управления.
+ :param rights: Заданные права администратора бота, по умолчанию словарь из конфигов.
+ """
+ bot_rights: ChatAdministratorRights = await bots.get_my_default_administrator_rights()
+
+ if bot_rights != rights:
+ await bots.set_my_default_administrator_rights(rights)
+
+
+ @staticmethod
+ @log(level='INFO', log_type='BOT', text='Обновление имени бота')
+ async def set_name(bots: Bot = bot, new_name: str = BotEdit.NAME) -> None:
+ """
+ Устанавливает имя бота из конфига.
+
+ :param bots: Объект бота для управления.
+ :param new_name: Новое имя бота, по умолчанию из конфигов.
+ """
+ current_name: str = (await bots.get_me()).first_name
+
+ if not (1 <= len(new_name) <= 32):
+ raise ValueError("Имя бота должно быть от 1 до 32 символов.")
+
+ if current_name != new_name:
+ await bots.set_my_name(new_name)
+
+
+ @staticmethod
+ @log(level='INFO', log_type='BOT', text='Обновление описания бота')
+ async def set_description(bots: Bot = bot, new_description: str = BotEdit.DESCRIPTION) -> None:
+ """
+ Устанавливает полное описание бота.
+
+ :param bots: Объект бота для управления.
+ :param new_description: Новое описание бота, по умолчанию из конфигов.
+ """
+ current_description: BotDescription = await bots.get_my_description()
+
+ if not (0 < len(new_description) <= 255):
+ raise ValueError("Описание должно быть от 1 до 255 символов.")
+
+ if current_description != new_description:
+ await bots.set_my_description(description=new_description)
+
+
+ @staticmethod
+ @log(level='INFO', log_type='BOT', text='Обновление короткого описания бота')
+ async def set_short_description(bots: Bot = bot, new_short: str = BotEdit.SHORT_DESCRIPTION) -> None:
+ """
+ Устанавливает короткое описание виджета.
+
+ :param bots: Объект бота для управления.
+ :param new_short: Новое короткое описание бота, по умолчанию из конфигов.
+ """
+ current_short: BotShortDescription = await bots.get_my_short_description()
+
+ if not (0 < len(new_short) <= 512):
+ raise ValueError("Короткое описание должно быть от 1 до 512 символов.")
+
+ if current_short != new_short:
+ await bots.set_my_short_description(short_description=new_short)
+
+
+ @classmethod
+ @log(level='INFO', log_type='START', text=f'Процесс запуска бота!!!!!')
+ async def setup(cls, bots: Bot = bot):
+ """
+ Выполняет полную настройку бота.
+
+ :param bots: Объект бота для управления.
+ """
+ await cls.webhook(bots=bots)
+ await cls.info(bots=bots)
+ await cls.set_administrator_rights(bots=bots)
+ await cls.set_description(bots=bots)
+ await cls.set_short_description(bots=bots)
+ await cls.set_name(bots=bots)
+ loggers.info(text=f"Бот @{BotInfo.username} запущен!!!")
diff --git a/bot/core/__init__.py b/bot/core/__init__.py
new file mode 100644
index 0000000..86e6831
--- /dev/null
+++ b/bot/core/__init__.py
@@ -0,0 +1 @@
+from .storage import *
diff --git a/BotCode/core/storage.py b/bot/core/storage.py
similarity index 97%
rename from BotCode/core/storage.py
rename to bot/core/storage.py
index 771169e..b7a576d 100644
--- a/BotCode/core/storage.py
+++ b/bot/core/storage.py
@@ -1,14 +1,16 @@
import json
from os import path, makedirs, listdir
from typing import Any, Dict, List, Optional
-from BotCode.config import POSTS_DIR
-from BotCode.loggers import logs
+from configs.config import Project
+from bot.loggers import logs
+# Настройки экспорта
+__all__ = ("storage", )
class PostStorage:
"""Класс для управления хранением постов и связанных уведомлений."""
- def __init__(self, posts_dir: str = POSTS_DIR):
+ def __init__(self, posts_dir: str = Project.POSTS_DIR):
self.posts_dir = posts_dir
self.global_posts: Dict[str, Dict[str, Any]] = {}
self.notifications: Dict[str, Dict[str, Any]] = {}
@@ -231,4 +233,4 @@ class PostStorage:
# Инициализация хранилища при импорте модуля
-storage = PostStorage()
+storage: PostStorage = PostStorage()
diff --git a/BotCode/handlers/__init__.py b/bot/handlers/__init__.py
similarity index 64%
rename from BotCode/handlers/__init__.py
rename to bot/handlers/__init__.py
index 87ad758..eee5458 100644
--- a/BotCode/handlers/__init__.py
+++ b/bot/handlers/__init__.py
@@ -1,13 +1,14 @@
from aiogram import Router
from .post import router as post_routers
from .commands import router as cmd_routers
-
from .callback import router as callback_router
from .inline import router as inline_router
-router = Router(name=__name__)
+# Настройка экспорта и роутера
+__all__ = ("router",)
+router: Router = Router(name="handlers_router")
-# Include routers with different priorities
+# Подключение роутеров
router.include_routers(
cmd_routers,
callback_router,
diff --git a/BotCode/handlers/callback.py b/bot/handlers/callback.py
similarity index 74%
rename from BotCode/handlers/callback.py
rename to bot/handlers/callback.py
index ae5f596..a207bbd 100644
--- a/BotCode/handlers/callback.py
+++ b/bot/handlers/callback.py
@@ -1,15 +1,16 @@
-# BotCode/handlers/callback.py
+from typing import Optional
+
from aiogram import Router, F
from aiogram.types import CallbackQuery
-from BotCode.core.storage import storage
+from bot.core import storage
-router = Router(name="callback_router")
+router: Router = Router(name="callback_router")
@router.callback_query(F.data.startswith("bt_"))
@router.callback_query(F.data.startswith("show_alert_"))
async def handle_button_alert(callback_query: CallbackQuery) -> None:
- key = callback_query.data
- user_id = callback_query.from_user.id
+ key: Optional[str] = callback_query.data
+ user_id: int = callback_query.from_user.id
# Получаем уведомление через хранилище
notif = storage.get_notification(key)
@@ -29,11 +30,8 @@ async def handle_button_alert(callback_query: CallbackQuery) -> None:
try:
await callback_query.answer(text=text, show_alert=show_alert)
- except Exception as e:
- try:
- await callback_query.answer(text="Произошла ошибка при отображении уведомления.", show_alert=True)
- except:
- pass
+ except Exception:
+ await callback_query.answer(text="Произошла ошибка при отображении уведомления.", show_alert=True)
@router.callback_query(F.data == "void")
@@ -44,5 +42,5 @@ async def handle_void_callback(callback_query: CallbackQuery) -> None:
"""
try:
await callback_query.answer()
- except Exception as e:
+ except Exception:
return
diff --git a/bot/handlers/commands/__init__.py b/bot/handlers/commands/__init__.py
new file mode 100644
index 0000000..d95704c
--- /dev/null
+++ b/bot/handlers/commands/__init__.py
@@ -0,0 +1,12 @@
+from aiogram import Router
+from .start import router as start_cmd_router
+from .help import router as help_cmd_router
+
+# Настройка экспорта и роутера
+__all__ = ('router',)
+router: Router = Router(name="cmd_router")
+
+# Подготовка роутера команд
+router.include_routers(start_cmd_router,
+ help_cmd_router,
+)
diff --git a/bot/handlers/commands/help.py b/bot/handlers/commands/help.py
new file mode 100644
index 0000000..c44b363
--- /dev/null
+++ b/bot/handlers/commands/help.py
@@ -0,0 +1,47 @@
+from aiogram import Router, F
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.types import Message, CallbackQuery, KeyboardButton
+from aiogram.utils.keyboard import ReplyKeyboardBuilder
+from aiogram.utils.i18n import gettext as _
+
+from bot.templates import msg_photo
+from bot.utils.interesting_facts import interesting_fact
+from middleware.loggers import log
+from bot.bots import BotInfo
+from configs import COMMANDS, BotEdit
+
+# Настройки экспорта и роутера
+__all__ = ("router",)
+CMD: str = "help".lower()
+router: Router = Router(name=f"{CMD}_cmd_router")
+
+
+@router.callback_query(F.data == CMD)
+@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
+@log(level='INFO', log_type=CMD.upper(), text=f"использовал команду /{CMD}")
+async def help_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
+ """
+ Обработчик команды /help
+
+ Args:
+ message (Message | CallbackQuery): Сообщение или callback-запрос от пользователя.
+ state (FSMContext): Состояние пользователя бота.
+ """
+ await state.clear()
+
+ # Создаем клавиатуру с кнопками
+ rkb: ReplyKeyboardBuilder = ReplyKeyboardBuilder()
+ rkb.row(KeyboardButton(text=_("Создать пост📔")))
+ rkb.row(KeyboardButton(text=_("Посмотреть список📋")))
+
+ # Формируем приветственное сообщение
+ text: str = _(
+ """Добро пожаловать, {name}!"""
+ ).format(
+ url=message.from_user.url if message.from_user else "",
+ name=message.from_user.first_name if message.from_user else "пользователь",
+ )
+
+ # Отправляем сообщение
+ await msg_photo(message=message, text=text, file='assets/start.jpg', markup=rkb)
diff --git a/bot/handlers/commands/start.py b/bot/handlers/commands/start.py
new file mode 100644
index 0000000..c83ed7b
--- /dev/null
+++ b/bot/handlers/commands/start.py
@@ -0,0 +1,57 @@
+from aiogram import Router, F
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.types import Message, CallbackQuery, KeyboardButton
+from aiogram.utils.keyboard import ReplyKeyboardBuilder
+from aiogram.utils.i18n import gettext as _
+
+from bot.templates import msg_photo
+from bot.utils.interesting_facts import interesting_fact
+from middleware.loggers import log
+from bot.bots import BotInfo
+from configs import COMMANDS, BotEdit
+
+# Настройки экспорта и роутера
+__all__ = ("router",)
+CMD: str = "start".lower()
+router: Router = Router(name=f"{CMD}_cmd_router")
+
+
+@router.callback_query(F.data == CMD)
+@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
+@log(level='INFO', log_type=CMD.upper(), text=f"использовал команду /{CMD}")
+async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
+ """
+ Обработчик команды /start
+
+ Args:
+ message (Message | CallbackQuery): Сообщение или callback-запрос от пользователя.
+ state (FSMContext): Состояние пользователя бота.
+ """
+ await state.clear()
+
+ # Создаем клавиатуру с кнопками
+ rkb: ReplyKeyboardBuilder = ReplyKeyboardBuilder()
+ rkb.row(KeyboardButton(text=_("Создать пост📔")))
+ rkb.row(KeyboardButton(text=_("Посмотреть список📋")))
+
+ # Формируем приветственное сообщение
+ text: str = _(
+ """Добро пожаловать, {name}!
+
+Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших историй!
+Моя цель — помочь вам сориентироваться и сделать ваши истории куда интереснее!
+Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре!
+
+Интересный факт:
+{fact}+""" + ).format( + url=message.from_user.url if message.from_user else "", + name=message.from_user.first_name if message.from_user else "пользователь", + bot_name=BotEdit.PROJECT_NAME, + fact=interesting_fact(), + ) + + # Отправляем сообщение + await msg_photo(message=message, text=text, file='assets/start.jpg', markup=rkb) diff --git a/BotCode/handlers/inline.py b/bot/handlers/inline.py similarity index 95% rename from BotCode/handlers/inline.py rename to bot/handlers/inline.py index 83686a4..e8e652d 100644 --- a/BotCode/handlers/inline.py +++ b/bot/handlers/inline.py @@ -11,12 +11,10 @@ from aiogram.types import ( ) from aiogram.utils.markdown import hide_link -from BotCode.core.storage import storage -from BotCode.utils import textmd2 -from BotCode.config import PARSE_MODE -from BotCode.loggers import logs +from bot.core import storage +from bot.loggers import logs -router = Router(name="inline_send") +router: Router = Router(name="inline_send") @@ -140,7 +138,7 @@ async def inline_query_handler(inline_query: InlineQuery): continue # Тело сообщения - text = textmd2(post.get("text", "")) + text = post.get("text", "") image = post.get("image", "") if image and image.startswith("http"): text = f"{hide_link(image)}{text}" @@ -154,10 +152,7 @@ async def inline_query_handler(inline_query: InlineQuery): title=f"Пост {post_id}", description=(post.get("text", "")[:100] + "...") if len(post.get("text", "")) > 100 else post.get( "text", ""), - input_message_content=InputTextMessageContent( - message_text=text, - parse_mode=PARSE_MODE - ), + input_message_content=InputTextMessageContent(message_text=text), reply_markup=markup ) ) @@ -176,4 +171,4 @@ async def inline_query_handler(inline_query: InlineQuery): __all__ = [ 'router', 'inline_query_handler' -] \ No newline at end of file +] diff --git a/bot/handlers/post/__init__.py b/bot/handlers/post/__init__.py new file mode 100644 index 0000000..aafc23e --- /dev/null +++ b/bot/handlers/post/__init__.py @@ -0,0 +1,13 @@ +from aiogram import Router +from .create_posts import router as posts_router +from .post_list import router as post_list_router + +# Настройки экспорта и роутера +__all__ = ("router", ) +router: Router = Router(name="post_router") + +# Подключение роутеров +router.include_routers( + post_list_router, + posts_router, +) diff --git a/bot/handlers/post/create_posts.py b/bot/handlers/post/create_posts.py new file mode 100644 index 0000000..68c4b68 --- /dev/null +++ b/bot/handlers/post/create_posts.py @@ -0,0 +1,420 @@ +# bot/modules/create_post.py +import re +import uuid +from threading import Lock + +from aiogram import Router, F +from aiogram.types import ( + Message, CallbackQuery, + InlineKeyboardButton, InlineKeyboardMarkup +) +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.context import FSMContext + +from bot.core import storage + +router: Router = Router(name="create_post_router") + + +class PostState(StatesGroup): + waiting_for_text = State() + waiting_for_privacy = State() + waiting_for_id = State() + waiting_for_image = State() + waiting_for_buttons = State() + preview = State() + editing_choice = State() + + +post_id_lock: Lock = Lock() + + +# --- Utility functions --- +def make_inline_markup(rows: list[list[InlineKeyboardButton]]) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def cancel_button() -> InlineKeyboardMarkup: + return make_inline_markup([[InlineKeyboardButton(text="Отмена", callback_data="cancel_creation")]]) + + +def privacy_markup(is_private: bool) -> InlineKeyboardMarkup: + toggle = InlineKeyboardButton( + text="🔒 Приватный" if is_private else "🔓 Публичный", + callback_data="toggle_privacy" + ) + cont = InlineKeyboardButton(text="Продолжить ➡️", callback_data="continue_creation") + return make_inline_markup([[toggle], [cont]]) + + +def parse_buttons(text: str, post_id: str) -> list[list[dict]]: + """ + Поддерживается синтаксис: + Текст | msg:Только для боссов | 123,456 | msg:Для всех остальных + Текст | ntf:Без алерта | 789 | msg:Нет доступа + """ + rows: list[list[dict]] = [] + button_index = 0 + + for line in text.splitlines(): + line = line.strip() + if not line: + continue + + # каждая строка может содержать несколько кнопок через ';' + btn_texts = [b.strip() for b in line.split(';') if b.strip()] + row: list[dict] = [] + + for raw in btn_texts: + parts = [p.strip() for p in raw.split('|')] + if len(parts) < 2: + raise ValueError(f"Неверный формат кнопки: '{raw}'") + + btn = {"text": parts[0]} + primary_notification = None + primary_alert = False + allowed_ids = None + unauthorized_message = None + + # обрабатываем параметры слева направо + for part in parts[1:]: + # URL / void + if part == "void": + btn["url"] = "http://void" + elif part.startswith("http") or part.startswith("tg://"): + btn["url"] = part + + # первое уведомление (msg: — с алертом) + elif part.startswith("msg:") and primary_notification is None: + primary_notification = part.split(":", 1)[1] + primary_alert = True + + # первое уведомление без алерта + elif part.startswith(("ntf:", "notification:")) and primary_notification is None: + primary_notification = part.split(":", 1)[1] + primary_alert = False + + # список разрешённых ID + elif re.fullmatch(r'\d+(?:\s*,\s*\d+)*', part): + allowed_ids = [int(x.strip()) for x in part.split(",")] + + # второе сообщение — для неавторизованных + elif part.startswith("msg:") and primary_notification is not None and allowed_ids is not None: + unauthorized_message = part.split(":", 1)[1] + + # копирование текста + elif part.startswith("copy:"): + btn["callback_data"] = f"copy_{uuid.uuid4().hex}" + btn["copy_text"] = part.split(":", 1)[1] + + # inline-параметры + elif part.startswith("inline:"): + btn["switch_inline_query"] = part.split(":", 1)[1] + elif part.startswith("inline_current:"): + btn["switch_inline_query_current_chat"] = part.split(":", 1)[1] + elif part.startswith("inline_chosen:"): + btn["switch_inline_query_chosen_chat"] = part.split(":", 1)[1] + + # произвольный callback_data (если ещё не задан) + else: + if "callback_data" not in btn and "url" not in btn: + btn["callback_data"] = part + + # если было уведомление — добавляем поля + if primary_notification is not None: + btn["callback_data"] = f"bt_{post_id}_{button_index}" + button_index += 1 + btn["notification"] = primary_notification + btn["show_alert"] = primary_alert + + if allowed_ids is not None: + btn["allowed_ids"] = allowed_ids + if unauthorized_message is not None: + btn["unauthorized_message"] = unauthorized_message + + # финализируем кнопку + row.append(btn) + + if row: + rows.append(row) + + return rows + + +# --- Handlers --- +@router.message(F.text == "Создать пост📔") +async def start_creation(message: Message, state: FSMContext) -> None: + await state.set_state(PostState.waiting_for_text) + await state.update_data(private=False, buttons=[]) + await message.reply( + text="Отправьте текст вашего поста:\nВы также можете использовать разметку(жирный, курсив и прочие)!", + reply_markup=cancel_button() + ) + + +@router.message(PostState.waiting_for_text) +async def got_text(message: Message, state: FSMContext) -> None: + html_text = message.html_text or message.text or message.caption or "" + await state.update_data(text=html_text) + await show_preview(message, state) + + +@router.callback_query(F.data == "toggle_privacy") +async def toggle_privacy(cq: CallbackQuery, state: FSMContext) -> None: + data = await state.get_data() + is_priv = not data.get('private', False) + await state.update_data(private=is_priv) + await cq.message.edit_reply_markup(reply_markup=privacy_markup(is_priv)) + await cq.answer() + + +@router.callback_query(F.data == "continue_creation") +async def continue_to_id(cq: CallbackQuery, state: FSMContext) -> None: + await state.set_state(PostState.waiting_for_id) + await cq.message.edit_text( + "Введите уникальный ID поста (латиница, цифры, подчёрки):\nСовет: инициалыРП_роль_тип_номер\nПример: sgrp_dottore_post_4") + await cq.answer() + + +@router.message(PostState.waiting_for_id) +async def got_id(message: Message, state: FSMContext) -> None: + pid = message.text.strip() + if not pid.replace('_', '').isalnum(): + await message.reply(text="ID должен содержать только латиницу, цифры и подчёркивания.", + reply_markup=cancel_button()) + return + with post_id_lock: + if not storage.is_post_available(pid): + await message.reply(text="Этот ID уже занят, введите другой:", reply_markup=cancel_button()) + return + + # Создаем клавиатуру с кнопкой "Без изображения" + image_markup = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🚫 Без изображения", callback_data="no_image")], + [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")] + ]) + + await state.update_data(post_id=pid) + await state.set_state(PostState.waiting_for_image) + await message.reply( + text="Отправьте ссылку на изображение:\nПример: https://img4.teletype.in/files/f2/47/...\n\nСовет! Сохраняйте фотографии в teletype, а после копируйте ссылку на фотографию!\n\nИли нажмите 'Без изображения'.", + reply_markup=image_markup + ) + + +@router.callback_query(F.data == "no_image", PostState.waiting_for_image) +async def no_image_callback(cq: CallbackQuery, state: FSMContext): + await state.update_data(image='') + await state.set_state(PostState.waiting_for_buttons) + await cq.message.delete() + + buttons_markup = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🚫 Без кнопок", callback_data="no_buttons")], + [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")] + ]) + + await cq.message.answer( + text="""Отправьте кнопки по шаблону: +Кнопка заглушка | void +Уведомление | msg:Для вас! +Уведомление в закрепе | ntf:Сообщение +Кнопка ссылка | https://google.com +Копирование | copy:Текст для копирования + +Для уведомлений с ограничением: +Уведомление | msg:Для вас! | 123,456 | msg:Для всех остальных! +Уведомление без алерта | ntf:Сообщение | 789 | msg:Нет доступа + +Разделять кнопки через ; +Кнопка1 | void ; Кнопка2 | void ; .... + +Или нажмите "Без кнопок".""", + reply_markup=buttons_markup, + parse_mode=None + ) + await cq.answer() + + +@router.message(PostState.waiting_for_image) +async def got_image(message: Message, state: FSMContext) -> None: + img: str = message.text.strip() + if img.lower() in ('нет', 'no', 'none', 'без изображения'): + img: str = '' + + await state.update_data(image=img) + await show_preview(message, state) + + +@router.callback_query(PostState.waiting_for_buttons, F.data == "no_buttons") +async def no_buttons_handler(callback: CallbackQuery, state: FSMContext) -> None: + await state.update_data(buttons=[]) + await show_preview(callback.message, state) + await callback.answer() + + +@router.callback_query(PostState.waiting_for_buttons, F.data == "finish_buttons") +async def finish_buttons_handler(callback: CallbackQuery, state: FSMContext) -> None: + data = await state.get_data() + + # Формируем финальные кнопки + final = [] + for row in data.get('buttons', []): + final_row = [] + for b in row: + btn = {"text": b["text"]} + if "url" in b: + btn["url"] = b["url"] + if "switch_inline_query" in b: + btn["switch_inline_query"] = b["switch_inline_query"] + if "callback_data" in b: + btn["callback_data"] = b["callback_data"] + if "notification" in b: + btn["notification"] = b["notification"] + btn["show_alert"] = b.get("show_alert", False) + final_row.append(btn) + final.append(final_row) + + await state.update_data(buttons=final) + await show_preview(callback.message, state) + await callback.answer() + + +@router.callback_query(F.data == "cancel_creation") +async def cancel_handler(callback: CallbackQuery, state: FSMContext): + await state.clear() + await callback.message.edit_text("❌ Создание поста отменено") + await callback.answer() + + +# --- Preview and Edit Handlers --- +async def show_preview(message: Message, state: FSMContext) -> None: + data = await state.get_data() + text = data.get('text', '') + image = data.get('image', '') + buttons = data.get('buttons', []) + private = data.get('private', False) + post_id = data.get('post_id', '') + + # Формируем текст предпросмотра + preview_text = f"ПРЕДПРОСМОТР ПОСТА\n\n{text}\n\n" + preview_text += f"🆔 ID:
{post_id}\n"
+ preview_text += f"🔒 Приватность: {'Приватный' if private else 'Публичный'}\n"
+
+ if image:
+ preview_text += f"🖼 Изображение: {image}\n"
+ else:
+ preview_text += f"🖼 Изображение: отсутствует\n"
+
+ if buttons:
+ preview_text += "\n🔘 Кнопки:\n"
+ for row in buttons:
+ preview_text += " | ".join([btn['text'] for btn in row]) + "\n"
+ else:
+ preview_text += "\n🔘 Кнопки: отсутствуют\n"
+
+ # Клавиатура предпросмотра
+ preview_markup = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="Изменить", callback_data="edit_post"),
+ InlineKeyboardButton(text="Подтвердить", callback_data="confirm_post")
+ ],
+ [
+ InlineKeyboardButton(text="Отменить создание", callback_data="cancel_creation")
+ ]
+ ])
+
+ await state.set_state(PostState.preview)
+ await message.answer(preview_text, reply_markup=preview_markup, disable_web_page_preview=True)
+
+
+@router.callback_query(PostState.preview, F.data == "edit_post")
+async def edit_post_handler(cq: CallbackQuery, state: FSMContext) -> None:
+ # Клавиатура выбора поля для редактирования
+ edit_markup = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="Текст", callback_data="edit_field:text"),
+ InlineKeyboardButton(text="Изображение", callback_data="edit_field:image"),
+ ],
+ [
+ InlineKeyboardButton(text="Кнопки", callback_data="edit_field:buttons"),
+ InlineKeyboardButton(text="ID", callback_data="edit_field:id"),
+ ],
+ [
+ InlineKeyboardButton(text="Приватность", callback_data="edit_field:privacy"),
+ InlineKeyboardButton(text="Назад", callback_data="back_to_preview"),
+ ]
+ ])
+
+ await cq.message.edit_text("Выберите что изменить:", reply_markup=edit_markup)
+ await state.set_state(PostState.editing_choice)
+ await cq.answer()
+
+
+@router.callback_query(PostState.editing_choice, F.data == "back_to_preview")
+async def back_to_preview(cq: CallbackQuery, state: FSMContext) -> None:
+ await show_preview(cq.message, state)
+ await cq.answer()
+
+
+@router.callback_query(PostState.editing_choice, F.data.startswith("edit_field:"))
+async def handle_field_edit(cq: CallbackQuery, state: FSMContext) -> None:
+ field = cq.data.split(":")[1]
+
+ if field == "text":
+ await state.set_state(PostState.waiting_for_text)
+ await cq.message.edit_text("Введите новый текст поста:", reply_markup=cancel_button())
+
+ elif field == "image":
+ await state.set_state(PostState.waiting_for_image)
+ markup = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🚫 Без изображения", callback_data="no_image")],
+ [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")]
+ ])
+ await cq.message.edit_text(
+ "Отправьте новую ссылку на изображение или нажмите 'Без изображения':",
+ reply_markup=markup
+ )
+
+ elif field == "buttons":
+ await state.set_state(PostState.waiting_for_buttons)
+ markup = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🚫 Без кнопок", callback_data="no_buttons")],
+ [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")]
+ ])
+ await cq.message.edit_text(
+ "Отправьте новые кнопки по шаблону или нажмите 'Без кнопок':",
+ reply_markup=markup
+ )
+
+ elif field == "id":
+ await state.set_state(PostState.waiting_for_id)
+ await cq.message.edit_text("Введите новый ID поста:", reply_markup=cancel_button())
+
+ elif field == "privacy":
+ data = await state.get_data()
+ await state.set_state(PostState.waiting_for_privacy)
+ await cq.message.edit_text(
+ "Измените приватность поста:",
+ reply_markup=privacy_markup(data.get('private', False))
+ )
+
+ await cq.answer()
+
+
+@router.callback_query(PostState.preview, F.data == "confirm_post")
+async def confirm_post_handler(cq: CallbackQuery, state: FSMContext) -> None:
+ data = await state.get_data()
+ post_id = data['post_id']
+
+ # Сохранение поста в хранилище
+ storage.save_post(post_id, {
+ 'text': data['text'],
+ 'image': data.get('image', ''),
+ 'buttons': data.get('buttons', []),
+ 'private': data['private'],
+ 'post_id': post_id
+ })
+
+ await cq.message.edit_text(f"✅ Пост успешно создан с ID: {post_id}")
+ await state.clear()
+ await cq.answer()
diff --git a/BotCode/handlers/post/post_list.py b/bot/handlers/post/post_list.py
similarity index 82%
rename from BotCode/handlers/post/post_list.py
rename to bot/handlers/post/post_list.py
index 6e0ab77..16bc518 100644
--- a/BotCode/handlers/post/post_list.py
+++ b/bot/handlers/post/post_list.py
@@ -1,5 +1,8 @@
from math import ceil
+from typing import Final
+
from aiogram import Router, F
+from aiogram.fsm.context import FSMContext
from aiogram.types import (
Message, CallbackQuery,
InlineKeyboardButton, InlineKeyboardMarkup,
@@ -8,14 +11,12 @@ from aiogram.types import (
from aiogram.exceptions import TelegramBadRequest
from aiogram.utils.markdown import hide_link
-from BotCode.core.storage import storage
-from BotCode.utils.pagination import create_pagination_buttons
-from BotCode.utils import textmd2
-from BotCode.config import PARSE_MODE
+from bot.core import storage
+from bot.utils import pagination_btn
-router = Router(name="posts_manager")
+router: Router = Router(name="posts_manager_router")
-PAGE_SIZE = 5
+PAGE_SIZE: Final[int] = 5
async def send_posts_list(
message: Message = None,
@@ -54,7 +55,7 @@ async def send_posts_list(
rows.append([btn])
# Пагинация
- nav_buttons = create_pagination_buttons(
+ nav_buttons = pagination_btn(
action="open_post_list",
page=page,
total_posts=total,
@@ -67,7 +68,7 @@ async def send_posts_list(
rows.append([InlineKeyboardButton(text="Закрыть❌", callback_data="cancel_list")])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
- header = "Список ваших постов:"
+ header: str = "Список ваших постов:"
try:
if callback_query:
@@ -83,21 +84,25 @@ async def send_posts_list(
# --- Хендлеры списка ---
@router.message(F.text.lower() == "посмотреть список📋")
-async def cmd_list(message: Message):
+async def cmd_list(message: Message, state: FSMContext):
+ # Сбрасываем состояние перед показом списка
+ await state.clear()
await send_posts_list(message=message)
@router.callback_query(F.data == "open_post_list")
-async def cb_open_list(cq: CallbackQuery):
+async def cb_open_list(cq: CallbackQuery, state: FSMContext):
+ await state.clear()
await send_posts_list(callback_query=cq)
await cq.answer()
-@router.callback_query(lambda c: c.data and c.data.startswith("open_post_list_page_"))
-async def cb_paginate(cq: CallbackQuery):
+@router.callback_query(F.data.startswith("open_post_list_page_"))
+async def cb_paginate(cq: CallbackQuery, state: FSMContext):
try:
page = int(cq.data.rsplit("_", 1)[-1])
except ValueError:
await cq.answer("Некорректная страница", show_alert=True)
return
+ await state.clear()
await send_posts_list(callback_query=cq, page=page)
await cq.answer()
@@ -106,9 +111,9 @@ async def cb_cancel(cq: CallbackQuery):
await cq.message.delete()
await cq.answer()
-# --- Просмотр отдельного поста ---
-@router.callback_query(lambda c: c.data and c.data.startswith("view_post_"))
+@router.callback_query(F.data.startswith("view_post_"))
async def view_post_callback(cq: CallbackQuery):
+ """Просмотр отдельного поста"""
pid = cq.data.replace("view_post_", "")
uid = cq.from_user.id
posts = storage.load_user_posts(uid)
@@ -117,7 +122,7 @@ async def view_post_callback(cq: CallbackQuery):
return
post = posts[pid]
- text = textmd2(post.get("text", ""))
+ text = post.get("text", "")
img = post.get("image", "")
if img.startswith("http"):
text = f"{hide_link(img)}{text}"
@@ -181,20 +186,24 @@ async def view_post_callback(cq: CallbackQuery):
InlineKeyboardButton(text="Удалить❌", callback_data=f"delete_post_{pid}"),
InlineKeyboardButton(text="Назад◀️", callback_data="open_post_list")
])
+ rows.append(
+ [InlineKeyboardButton(text="Отправить↪️", switch_inline_query=f"{pid}")])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
- await cq.message.answer(text=text, reply_markup=keyboard, parse_mode=PARSE_MODE)
+ await cq.message.answer(text=text, reply_markup=keyboard)
await cq.message.delete()
await cq.answer()
-# --- Удаление поста ---
+
@router.callback_query(lambda c: c.data and c.data.startswith("delete_post_"))
-async def delete_post_callback(cq: CallbackQuery):
+async def delete_post_callback(cq: CallbackQuery, state: FSMContext):
+ """Удаление поста."""
pid = cq.data.replace("delete_post_", "")
uid = cq.from_user.id
if storage.delete_user_post(uid, pid):
await cq.answer(f"Пост {pid} удалён")
+ await state.clear()
await send_posts_list(callback_query=cq)
else:
- await cq.answer("Не удалось удалить пост", show_alert=True)
+ await cq.answer(text="Не удалось удалить пост", show_alert=True)
diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py
new file mode 100644
index 0000000..5908280
--- /dev/null
+++ b/bot/keyboards/__init__.py
@@ -0,0 +1 @@
+# bot/keyboards/__init__.py
diff --git a/bot/keyboards/inline/__init__.py b/bot/keyboards/inline/__init__.py
new file mode 100644
index 0000000..a5225a3
--- /dev/null
+++ b/bot/keyboards/inline/__init__.py
@@ -0,0 +1,3 @@
+# bot/keyboards/inline/__init__.py
+
+from .decision import *
diff --git a/bot/keyboards/inline/decision.py b/bot/keyboards/inline/decision.py
new file mode 100644
index 0000000..555f274
--- /dev/null
+++ b/bot/keyboards/inline/decision.py
@@ -0,0 +1,22 @@
+# bot/keyboards/decision.py
+
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+# Настройка экспорта
+__all__ = ("get_decision_keyboard",)
+
+def get_decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
+ """
+ Создание клавиатуры принять\отклонить.
+
+ :param thread_id: Айди запроса.
+ :param kind: Вид предполагаемого действия.
+ :return: Разметку клавиатуры для сообщения бота.
+ """
+ ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
+ ikb.row(
+ InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
+ InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
+ )
+ return ikb.as_markup()
diff --git a/bot/keyboards/reply/__init__.py b/bot/keyboards/reply/__init__.py
new file mode 100644
index 0000000..753a3fc
--- /dev/null
+++ b/bot/keyboards/reply/__init__.py
@@ -0,0 +1,2 @@
+# bot/keyboards/reply/__init__.py
+
diff --git a/BotCode/loggers/__init__.py b/bot/loggers/__init__.py
similarity index 100%
rename from BotCode/loggers/__init__.py
rename to bot/loggers/__init__.py
diff --git a/BotCode/loggers/logs.py b/bot/loggers/logs.py
similarity index 99%
rename from BotCode/loggers/logs.py
rename to bot/loggers/logs.py
index 14d4fc8..6adfcb8 100644
--- a/BotCode/loggers/logs.py
+++ b/bot/loggers/logs.py
@@ -16,7 +16,7 @@ from loguru import logger
from aiogram.types import Message, User
try:
- from config import LogConfig
+ from configs.config import LogConfig
except ImportError:
class LogConfig:
"""Запасные настройки логирования, если config недоступен."""
diff --git a/bot/templates/__init__.py b/bot/templates/__init__.py
new file mode 100644
index 0000000..b969334
--- /dev/null
+++ b/bot/templates/__init__.py
@@ -0,0 +1 @@
+from .message_callback import *
diff --git a/bot/templates/message_callback.py b/bot/templates/message_callback.py
new file mode 100644
index 0000000..aaa36f0
--- /dev/null
+++ b/bot/templates/message_callback.py
@@ -0,0 +1,77 @@
+from typing import Union
+
+from aiogram.types import FSInputFile, CallbackQuery, Message, ReplyKeyboardMarkup, InlineKeyboardMarkup
+from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
+
+# Настройка экспорта
+__all__ = ('msg', 'msg_photo')
+
+
+async def msg(
+ message: Message | CallbackQuery,
+ text: str,
+ markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None
+) -> None:
+ """
+ Шаблон для ответа на сообщение текстом.
+ :param message: Объект сообщения или callback-запроса.
+ :param text: Текст отправного сообщения от бота.
+ :param markup: Кнопки сообщения (инлайн или реплай).
+ """
+ # Преобразуем клавиатуру
+ reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None
+ if markup:
+ if isinstance(markup, InlineKeyboardBuilder):
+ reply_markup = markup.as_markup()
+ elif isinstance(markup, ReplyKeyboardBuilder):
+ reply_markup = markup.as_markup(resize_keyboard=True)
+
+ # Обработчик ответа на сообщение
+ if isinstance(message, Message):
+ await message.reply(
+ text=text,
+ reply_markup=reply_markup
+ )
+ # Обработчик ответа на callback
+ else:
+ await message.message.reply(
+ text=text,
+ reply_markup=reply_markup
+ )
+
+
+async def msg_photo(
+ message: Message | CallbackQuery,
+ text: str,
+ file: str,
+ markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None
+) -> None:
+ """
+ Шаблон для ответа на сообщение фотографией.
+ :param message: Объект сообщения или callback-запроса.
+ :param file: Путь к фотографии для ответа.
+ :param text: Подпись к фото.
+ :param markup: Кнопки сообщения (инлайн или реплай).
+ """
+ # Преобразуем клавиатуру
+ reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None
+ if markup:
+ if isinstance(markup, InlineKeyboardBuilder):
+ reply_markup = markup.as_markup()
+ elif isinstance(markup, ReplyKeyboardBuilder):
+ reply_markup = markup.as_markup(resize_keyboard=True)
+
+ # Обработчик ответа на сообщение
+ if isinstance(message, Message):
+ await message.reply_photo(
+ photo=FSInputFile(file),
+ caption=text,
+ reply_markup=reply_markup
+ )
+ # Обработчик ответа на callback
+ else:
+ await message.message.reply_photo(
+ photo=FSInputFile(file),
+ caption=text,
+ reply_markup=reply_markup
+ )
diff --git a/BotCode/utils/__init__.py b/bot/utils/__init__.py
similarity index 70%
rename from BotCode/utils/__init__.py
rename to bot/utils/__init__.py
index c74a0a3..e539ef4 100644
--- a/BotCode/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,3 +1,4 @@
+from .interesting_facts import *
from .md2_escape import *
from .usernames import *
from .pagination import *
diff --git a/bot/utils/interesting_facts.py b/bot/utils/interesting_facts.py
new file mode 100644
index 0000000..5a799c0
--- /dev/null
+++ b/bot/utils/interesting_facts.py
@@ -0,0 +1,29 @@
+from random import choice
+
+from configs.config import Lists
+
+# Настройки экспорта
+__all__ = ("interesting_fact",)
+
+
+def interesting_fact(mode: str = "факт", lists: list[str] = None) -> str:
+ """
+ Возвращает случайный факт, анекдот или цитату, в зависимости от режима.
+
+ :param mode: Строка, определяющая тип контента ("факт", "анекдот", "цитата").
+ :param lists: Необязательный список строк, из которого можно выбирать вручную.
+ :return: Случайный элемент из соответствующего списка.
+ """
+ if lists is not None:
+ return choice(lists)
+
+ mode: str = mode.lower()
+
+ if mode == "анекдот":
+ source: list[str] = Lists.jokes
+ elif mode == "цитата":
+ source: list[str] = Lists.quotes
+ else:
+ source: list[str] = Lists.facts
+
+ return choice(source)
diff --git a/BotCode/utils/md2_escape.py b/bot/utils/md2_escape.py
similarity index 91%
rename from BotCode/utils/md2_escape.py
rename to bot/utils/md2_escape.py
index 65e8ac2..d248ded 100644
--- a/BotCode/utils/md2_escape.py
+++ b/bot/utils/md2_escape.py
@@ -1,11 +1,11 @@
-# BotCode/utils/md2_escape.py
-from BotCode.config import PARSE_MODE
+from re import sub, escape
+from configs.config import BotSettings
# Настройка экспорта в модули
__all__ = ("textmd2",)
def textmd2(msg: str,
- parse_mode: str = PARSE_MODE,
+ parse_mode: str = BotSettings.PARSE_MODE,
special_chars: str = r"_*[]()~`>#+-=|{}.!") -> str:
"""
Экранирует специальные символы MarkdownV2 в переданном тексте.
@@ -18,7 +18,6 @@ def textmd2(msg: str,
:raises TypeError: Если передан не строковый тип данных.
:raises ValueError: Если parse_mode задан некорректно.
"""
- from re import sub, escape
if not isinstance(msg, str):
raise TypeError(f"Ожидается строка, но получено {type(msg).__name__}")
diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py
new file mode 100644
index 0000000..ff93cbf
--- /dev/null
+++ b/bot/utils/pagination.py
@@ -0,0 +1,28 @@
+from aiogram.types import InlineKeyboardButton
+
+# Настройка экспорта в модули
+__all__ = ('pagination_btn',)
+
+def pagination_btn(action: str,
+ page: int = 0,
+ total_posts: int = 0,
+ bt_page: int = 5) -> list[InlineKeyboardButton]:
+ """
+ Создает кнопки для пагинации.
+
+ :param action: Действие в котором нужна пангинация.
+ :param page: Номер начальной страницы, по умолчанию 0.
+ :param total_posts: Количество постов.
+ :param bt_page: Количество кнопок на одной странице.
+ :return: Готовый лист списка инлайн-кнопок.
+ """
+ navigation_buttons: list[InlineKeyboardButton] = []
+ if page > 0:
+ navigation_buttons.append(InlineKeyboardButton(
+ text="←", callback_data=f"{action}_page_{page - 1}"
+ ))
+ if (page + 1) * bt_page < total_posts:
+ navigation_buttons.append(InlineKeyboardButton(
+ text="→", callback_data=f"{action}_page_{page + 1}"
+ ))
+ return navigation_buttons
diff --git a/BotCode/utils/usernames.py b/bot/utils/usernames.py
similarity index 97%
rename from BotCode/utils/usernames.py
rename to bot/utils/usernames.py
index bfba6ae..ad71431 100644
--- a/BotCode/utils/usernames.py
+++ b/bot/utils/usernames.py
@@ -1,4 +1,3 @@
-# BotCode/utils/username.py
from aiogram.types import Message
# Настройка экспорта в модули
diff --git a/configs/__init__.py b/configs/__init__.py
new file mode 100644
index 0000000..3ace3f5
--- /dev/null
+++ b/configs/__init__.py
@@ -0,0 +1,2 @@
+from .config import *
+from .cmd_list import *
diff --git a/configs/cmd_list.py b/configs/cmd_list.py
new file mode 100644
index 0000000..ab70e15
--- /dev/null
+++ b/configs/cmd_list.py
@@ -0,0 +1,77 @@
+from typing import Final
+
+# Список команд по ключу
+COMMANDS: Final[dict[str, list[str]]] = {
+ "start": [
+ "start", "старт", "почати",
+ "ыефке", "cnfhn", "on", "вкл", "щт", "drk"
+ ],
+ "help": [
+ "help", "помощь", "допомога",
+ "рудзщь", "dopomoga", "?"
+ ],
+ "menu": [
+ "menu", "меню", "менюшка",
+ "ьщкф", "menyu"
+ ],
+ "create": [
+ "create", "создать", "створити",
+ "сщзду", "sozdat", "stvoriti"
+ ],
+ "report": [
+ "report", "репорт", "скарга",
+ "кщзщтв", "repert"
+ ],
+ "mute": [
+ "mute", "заглушить", "заглушити",
+ "угуыщцук", "zaglushit"
+ ],
+ "kick": [
+ "kick", "кик", "викинути",
+ "куиф", "vikynuty"
+ ],
+ "ban": [
+ "ban", "бан", "забанити",
+ "ьфд", "zabanyty"
+ ],
+ "stats": [
+ "stats", "статистика", "статистика",
+ "ыпщз", "statystyka"
+ ],
+ "settings": [
+ "settings", "настройки", "налаштування",
+ "гшеукефьз", "nastroyky"
+ ],
+ "info": [
+ "info", "инфо", "інфо",
+ "шкещ", "info"
+ ],
+ "feedback": [
+ "feedback", "обратная связь", "зворотній зв’язок",
+ "гуеекфьз", "obratnaia_svyaz"
+ ],
+ "subscribe": [
+ "subscribe", "подписаться", "підписатися",
+ "подписатсь", "pidpysatysia"
+ ],
+ "unsubscribe": [
+ "unsubscribe", "отписаться", "відписатися",
+ "отписаться", "vidpysatysia"
+ ],
+ "language": [
+ "language", "язык", "мова",
+ "йцукефь", "mova"
+ ],
+ "cancel": [
+ "cancel", "отмена", "скасувати",
+ "утпщге", "skasuvaty"
+ ],
+ "list": [
+ "list", "список", "список",
+ "дшззщк", "spysok"
+ ],
+ "forward": [
+ "forward", "переслать", "переслати",
+ "дшпекщву", "pereslaty"
+ ],
+}
diff --git a/configs/config.py b/configs/config.py
new file mode 100644
index 0000000..00772b5
--- /dev/null
+++ b/configs/config.py
@@ -0,0 +1,381 @@
+from pathlib import Path
+from urllib.parse import urlparse
+from typing import ClassVar, Final, Optional, Any
+
+from pydantic import field_validator, model_validator, HttpUrl
+from pydantic_settings import BaseSettings, SettingsConfigDict
+from aiogram.types import ChatAdministratorRights
+
+
+class Settings(BaseSettings):
+ """Улучшенный класс настроек с комплексной валидацией"""
+
+ # Конфигурация загрузки переменных окружения
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ extra="ignore",
+ case_sensitive=False,
+ validate_default=True,
+ )
+
+ # Режимы и базовые параметры
+ PYTHONUNBUFFERED: str = "1"
+ LOCALE_PATH: str = "locales"
+
+ DEBUG: bool = False
+ OWNER: str = "@verdise"
+
+ # Токены бота
+ BOT_TOKEN: Optional[str] = None
+ BOT_DEBUG_TOKEN: Optional[str] = None
+
+ # Параметры сообщений
+ PARSE_MODE: str = "HTML"
+ ENCOD: str = "utf-8"
+ TIME_FORMAT: str = "%Y-%m-%d %H:%M:%S"
+ PREFIX: str = "/!.&?"
+ BOT_LANGUAGE: str = "Aiogram3"
+
+ # Настройки сообщений
+ DISABLE_NOTIFICATION: bool = False
+ PROTECT_CONTENT: bool = False
+ ALLOW_SENDING_WITHOUT_REPLY: bool = True
+ LINK_PREVIEW_IS_DISABLED: bool = False
+ LINK_PREVIEW_PREFER_SMALL_MEDIA: bool = False
+ LINK_PREVIEW_PREFER_LARGE_MEDIA: bool = True
+ LINK_PREVIEW_SHOW_ABOVE_TEXT: bool = True
+ SHOW_CAPTION_ABOVE_MEDIA: bool = False
+
+ # Разрешения и логирование
+ BOT_EDIT: bool = False
+ START_INFO_CONSOLE: bool = True
+ START_INFO_TO_FILE: bool = True
+ LOG_CONSOLE: bool = True
+ LOG_FILE: bool = True
+ LOG_DIR: Path = Path('Logs')
+ LOG_FILE_INFO: Path = Path('bot_info.log')
+
+ # Вебхук
+ WEBHOOK: bool = False
+ WEBHOOK_HOST: str = "https://bot_1.primo.dpdns.org"
+ WEBHOOK_PATH: str = "/webhook"
+ WEBHOOK_URL: str = f"{WEBHOOK_HOST}{WEBHOOK_PATH}"
+
+ # API ключи
+ API_KEY: Optional[str] = None
+ WEB_API_KEY: Optional[str] = None
+ WEATHER_API_KEY: Optional[str] = None
+
+ # Пользовательские данные
+ TG_API_UID: int = 0
+ TG_API_HASH: Optional[str] = None
+
+ # Идентификаторы
+ ADMIN_ID: int = 0
+ MODERATOR_ID: int = 0
+ IMPORTANT_ID: int = 0
+ IMPORTANT_GROUP_ID: int = 0
+ IMPORTANT_CHANNEL_ID: int = 0
+ SUPPORT_CHAT_ID: int = 0
+
+ # Настройки бота
+ PROJECT_NAME: str = "PRIMO"
+ BOT_NAME: str = "Первозданная Жемчужина"
+ BOT_DESCRIPTION: Optional[str] = None
+ BOT_SHORT_DESCRIPTION: Optional[str] = None
+
+ # Ролевой проект
+ RP_NAME: Optional[str] = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥"
+ INFO_URL: Optional[HttpUrl] = None
+ RP_OWNER: Optional[str] = None
+
+ # Права администратора
+ ANONYMOUS: bool = False
+ MANAGE_CHAT: bool = True
+ CHANGE_INFO: bool = True
+ PROMOTE_MEMBERS: bool = True
+ RESTRICT_MEMBERS: bool = True
+ POST_MESSAGE: bool = True
+ MANAGE_TOPICS: bool = True
+ INVITE_USER: bool = True
+ DELETE_MESSAGES: bool = True
+ MANAGE_VIDEO_CHATS: bool = True
+ EDIT_MESSAGES: bool = True
+ PIN_MESSAGE: bool = True
+ POST_STORIES: bool = True
+ EDIT_STORIES: bool = True
+ DELETE_STORIES: bool = True
+
+ # ================= ВАЛИДАТОРЫ =================
+
+ @field_validator('PYTHONUNBUFFERED')
+ def validate_unbuffered(cls, v: str) -> str:
+ """Проверка корректности значения буферизации"""
+ if v not in ('0', '1'):
+ raise ValueError("PYTHONUNBUFFERED должен быть '0' или '1'")
+ return v
+
+ @field_validator('PARSE_MODE')
+ def validate_parse_mode(cls, v: str) -> str:
+ """Проверка допустимого режима разметки"""
+ allowed_modes = {"HTML", "Markdown", "MarkdownV2"}
+ if v not in allowed_modes:
+ raise ValueError(f"Недопустимый PARSE_MODE. Допустимые значения: {', '.join(allowed_modes)}")
+ return v
+
+ @field_validator('PREFIX')
+ def validate_prefix(cls, v: str) -> str:
+ """Очистка и проверка префиксов команд"""
+ cleaned = ''.join(sorted(set(v), key=v.index)) # Удаление дубликатов с сохранением порядка
+ if len(cleaned) < 1:
+ raise ValueError("PREFIX должен содержать хотя бы один символ")
+ return cleaned
+
+ @field_validator('LOG_DIR', 'LOG_FILE_INFO', mode='before')
+ def validate_paths(cls, v: Any) -> Path:
+ """Преобразование путей в объекты Path"""
+ return Path(v) if isinstance(v, str) else v
+
+ @field_validator('TG_API_UID', 'ADMIN_ID', 'MODERATOR_ID')
+ def validate_ids(cls, v: int) -> int:
+ """Проверка корректности идентификаторов"""
+ if v < 0:
+ raise ValueError("ID не может быть отрицательным")
+ return v
+
+ @field_validator('WEBHOOK_URL')
+ def validate_webhook_url(cls, v: str) -> str:
+ """Базовая проверка URL вебхука"""
+ parsed = urlparse(v)
+ if not all([parsed.scheme, parsed.netloc]):
+ raise ValueError("Некорректный URL вебхука")
+ return v
+
+ @field_validator('BOT_NAME', 'PROJECT_NAME', 'OWNER')
+ def validate_non_empty(cls, v: str) -> str:
+ """Проверка непустых строк"""
+ if not v.strip():
+ raise ValueError("Поле не может быть пустым")
+ return v
+
+ @model_validator(mode='after')
+ def validate_bot_token(cls, setting: "Settings") -> "Settings":
+ """Проверка наличия необходимых токенов"""
+ if setting.DEBUG and not setting.BOT_DEBUG_TOKEN:
+ raise ValueError("Требуется BOT_DEBUG_TOKEN в режиме DEBUG")
+ if not setting.DEBUG and not setting.BOT_TOKEN:
+ raise ValueError("Требуется BOT_TOKEN для рабочего режима")
+ return setting
+
+ @model_validator(mode='after')
+ def validate_webhook_config(cls, setting: "Settings") -> "Settings":
+ """Проверка конфигурации вебхука"""
+ if setting.WEBHOOK and not setting.WEBHOOK_URL:
+ raise ValueError("WEBHOOK_URL обязателен при включенном WEBHOOK")
+ return setting
+
+ @model_validator(mode='after')
+ def validate_logging_paths(cls, setting: "Settings") -> "Settings":
+ """Создание директорий для логов при необходимости"""
+ if setting.LOG_FILE and not setting.LOG_DIR.exists():
+ setting.LOG_DIR.mkdir(parents=True, exist_ok=True) # Исправлено: setting вместо settings
+ return setting
+
+ @model_validator(mode='after')
+ def set_dynamic_descriptions(cls, setting: "Settings") -> "Settings":
+ """Динамическая установка описаний бота"""
+ if setting.BOT_DESCRIPTION is None:
+ # Исправлено: setting вместо settings
+ setting.BOT_DESCRIPTION = f"Ваш помощник в удивительные миры! Prod. by:『{setting.OWNER}』"
+ if setting.BOT_SHORT_DESCRIPTION is None:
+ setting.BOT_SHORT_DESCRIPTION = f"Тех.поддержка: {setting.OWNER}"
+ return setting
+
+ # ================= СВОЙСТВА =================
+
+ @property
+ def rights(self) -> ChatAdministratorRights:
+ """Права администратора бота"""
+ return ChatAdministratorRights(
+ is_anonymous=self.ANONYMOUS,
+ can_manage_chat=self.MANAGE_CHAT,
+ can_delete_messages=self.DELETE_MESSAGES,
+ can_manage_video_chats=self.MANAGE_VIDEO_CHATS,
+ can_restrict_members=self.RESTRICT_MEMBERS,
+ can_promote_members=self.PROMOTE_MEMBERS,
+ can_change_info=self.CHANGE_INFO,
+ can_invite_users=self.INVITE_USER,
+ can_post_stories=self.POST_STORIES,
+ can_edit_stories=self.EDIT_STORIES,
+ can_delete_stories=self.DELETE_STORIES,
+ can_post_messages=self.POST_MESSAGE,
+ can_edit_messages=self.EDIT_MESSAGES,
+ can_pin_messages=self.PIN_MESSAGE,
+ can_manage_topics=self.MANAGE_TOPICS,
+ )
+
+ @property
+ def active_bot_token(self) -> str:
+ """Активный токен бота в зависимости от режима"""
+ token = self.BOT_DEBUG_TOKEN if self.DEBUG else self.BOT_TOKEN
+ if not token:
+ raise ValueError("Активный токен бота отсутствует")
+ return token
+
+ @property
+ def log_dir_absolute(self) -> Path:
+ """Абсолютный путь к директории логов"""
+ return self.LOG_DIR.absolute()
+
+
+# Инициализация настроек
+settings: Settings = Settings()
+
+
+# Классы для обратной совместимости и удобства использования
+
+class BotSettings:
+ """Алиасы для настроек бота."""
+ DEBUG: Final[bool] = settings.DEBUG
+ OWNER: Final[str] = settings.OWNER
+ BOT_TOKEN: Final[str] = settings.active_bot_token
+ PARSE_MODE: Final[str] = settings.PARSE_MODE
+ ENCOD: Final[str] = settings.ENCOD
+ TIME_FORMAT: Final[str] = settings.TIME_FORMAT
+ PREFIX: Final[str] = settings.PREFIX
+ BOT_LANGUAGE: Final[str] = settings.BOT_LANGUAGE
+ DISABLE_NOTIFICATION: Final[bool] = settings.DISABLE_NOTIFICATION
+ PROTECT_CONTENT: Final[bool] = settings.PROTECT_CONTENT
+ ALLOW_SENDING_WITHOUT_REPLY: Final[bool] = settings.ALLOW_SENDING_WITHOUT_REPLY
+ LINK_PREVIEW_IS_DISABLED: Final[bool] = settings.LINK_PREVIEW_IS_DISABLED
+ LINK_PREVIEW_PREFER_SMALL_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_SMALL_MEDIA
+ LINK_PREVIEW_PREFER_LARGE_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_LARGE_MEDIA
+ LINK_PREVIEW_SHOW_ABOVE_TEXT: Final[bool] = settings.LINK_PREVIEW_SHOW_ABOVE_TEXT
+ SHOW_CAPTION_ABOVE_MEDIA: Final[bool] = settings.SHOW_CAPTION_ABOVE_MEDIA
+
+
+class Permission:
+ """Алиасы для разрешений."""
+ BOT_EDIT: Final[bool] = settings.BOT_EDIT
+ START_INFO_CONSOLE: Final[bool] = settings.START_INFO_CONSOLE
+ START_INFO_TO_FILE: Final[bool] = settings.START_INFO_TO_FILE
+
+
+class LogConfig:
+ """Алиасы для конфигурации логов."""
+ CONSOLE: Final[bool] = settings.LOG_CONSOLE
+ FILE: Final[bool] = settings.LOG_FILE
+ DIR: Final[Path] = settings.LOG_DIR
+ FILE_INFO: Final[Path] = settings.LOG_FILE_INFO
+ ROTATION: ClassVar[str] = '100 MB'
+ RETENTION: ClassVar[str] = '7 days'
+
+
+class Webhook:
+ """Алиасы для вебхука."""
+ WEBHOOK: Final[bool] = settings.WEBHOOK
+ WEBHOOK_HOST = settings.WEBHOOK_HOST
+ WEBHOOK_PATH = settings.WEBHOOK_PATH
+ WEBHOOK_URL = settings.WEBHOOK_URL
+
+
+class APISettings:
+ """Алиасы для API."""
+ API_KEY: Final[Optional[str]] = settings.API_KEY
+ WEB_API_KEY: Final[Optional[str]] = settings.WEB_API_KEY
+ WEATHER_API_KEY: Final[Optional[str]] = settings.WEATHER_API_KEY
+
+
+class UserIn:
+ """Алиасы для пользовательских данных."""
+ TG_API_UID: Final[int] = settings.TG_API_UID
+ TG_API_HASH: Final[Optional[str]] = settings.TG_API_HASH
+
+
+class ImportantID:
+ """Алиасы для важных ID."""
+ ADMIN_ID: Final[int] = settings.ADMIN_ID
+ MODERATOR_ID: Final[int] = settings.MODERATOR_ID
+ IMPORTANT_ID: Final[int] = settings.IMPORTANT_ID
+ IMPORTANT_GROUP_ID: Final[int] = settings.IMPORTANT_GROUP_ID
+ IMPORTANT_CHANNEL_ID: Final[int] = settings.IMPORTANT_CHANNEL_ID
+
+
+class BotEdit:
+ """Алиасы для настроек редактирования бота."""
+ ALLOW_PERMISSION: Final[bool] = settings.BOT_EDIT
+ PROJECT_NAME: Final[str] = settings.PROJECT_NAME
+ NAME: Final[str] = settings.BOT_NAME
+ DESCRIPTION: Final[str] = settings.BOT_DESCRIPTION
+ SHORT_DESCRIPTION: Final[str] = settings.BOT_SHORT_DESCRIPTION
+ ANONYMOUS: Final[bool] = settings.ANONYMOUS
+ MANAGE_CHAT: Final[bool] = settings.MANAGE_CHAT
+ CHANGE_INFO: Final[bool] = settings.CHANGE_INFO
+ PROMOTE_MEMBERS: Final[bool] = settings.PROMOTE_MEMBERS
+ RESTRICT_MEMBERS: Final[bool] = settings.RESTRICT_MEMBERS
+ POST_MESSAGE: Final[bool] = settings.POST_MESSAGE
+ MANAGE_TOPICS: Final[bool] = settings.MANAGE_TOPICS
+ INVITE_USER: Final[bool] = settings.INVITE_USER
+ DELETE_MESSAGES: Final[bool] = settings.DELETE_MESSAGES
+ MANAGE_VIDEO_CHATS: Final[bool] = settings.MANAGE_VIDEO_CHATS
+ EDIT_MESSAGES: Final[bool] = settings.EDIT_MESSAGES
+ PIN_MESSAGE: Final[bool] = settings.PIN_MESSAGE
+ POST_STORIES: Final[bool] = settings.POST_STORIES
+ EDIT_STORIES: Final[bool] = settings.EDIT_STORIES
+ DELETE_STORIES: Final[bool] = settings.DELETE_STORIES
+ RIGHTS: Final[ChatAdministratorRights] = settings.rights
+
+
+class RpValue:
+ """Переменные связанные с ролевым проектом."""
+ RP_NAME: Final[str] = settings.RP_NAME
+ INFO_URL: str = settings.INFO_URL
+ RP_OWNER: str = settings.RP_OWNER
+
+
+class Project:
+ POSTS_DIR: ClassVar[Path] = Path('posts')
+
+
+class Lists:
+ """Интересные списки фактов, цитат и анекдотов."""
+ facts: list[str] = [
+ "Python был создан Гвидо ван Россумом в 1991 году.",
+ "Имена Python и Monty Python связаны — язык назван в честь шоу.",
+ "Python — язык с динамической типизацией.",
+ "В Python всё является объектом, даже функции и типы данных.",
+ "Списки в Python — это изменяемые коллекции, в отличие от кортежей.",
+ "Python поддерживает парадигмы ООП, функционального и императивного программирования.",
+ "Zen of Python можно увидеть, набрав `import this` в интерпретаторе.",
+ ]
+ jokes: list[str] = [
+ "1",
+ "2",
+ "3",
+ "4",
+ ]
+ quotes: list[str] = [
+ "5",
+ "6",
+ "7",
+ "8",
+ ]
+
+
+
+# Экспорт совместимых компонентов
+__all__ = (
+ "BotSettings",
+ "LogConfig",
+ "Webhook",
+ "APISettings",
+ "UserIn",
+ "ImportantID",
+ "Permission",
+ "BotEdit",
+ "Project",
+ "RpValue",
+ 'settings',
+ 'Lists',
+)
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e45f863
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,8 @@
+services:
+ app:
+ build: .
+ container_name: SystemReverseRPBot
+ volumes:
+ - .:/app
+ working_dir: /app
+ command: python main.py
diff --git a/env_example b/env_example
new file mode 100644
index 0000000..5bd9dfa
--- /dev/null
+++ b/env_example
@@ -0,0 +1,92 @@
+# Токены бота
+BOT_TOKEN=your_bot_token_here
+BOT_DEBUG_TOKEN=your_debug_bot_token_here
+
+# Режим отладки
+DEBUG=False
+
+
+# Владелец бота
+OWNER=@verdise
+
+# Основные настройки
+PARSE_MODE=HTML
+ENCOD=utf-8
+TIME_FORMAT=%Y-%m-%d %H:%M:%S
+PREFIX=/!.&?
+BOT_LANGUAGE=Aiogram3
+
+
+# Настройки сообщений
+DISABLE_NOTIFICATION=False
+PROTECT_CONTENT=False
+ALLOW_SENDING_WITHOUT_REPLY=True
+LINK_PREVIEW_IS_DISABLED=False
+LINK_PREVIEW_PREFER_SMALL_MEDIA=False
+LINK_PREVIEW_PREFER_LARGE_MEDIA=True
+LINK_PREVIEW_SHOW_ABOVE_TEXT=False
+SHOW_CAPTION_ABOVE_MEDIA=False
+
+# Разрешения
+BOT_EDIT=False
+START_INFO_CONSOLE=True
+START_INFO_TO_FILE=True
+
+# Логирование
+LOG_CONSOLE=True
+LOG_FILE=True
+LOG_DIR=Logs
+LOG_FILE_INFO=bot_info.log
+
+
+# Вебхук
+WEBHOOK=False
+
+# API ключи
+API_KEY=your_api_key
+WEB_API_KEY=your_web_api_key
+WEATHER_API_KEY=your_weather_api_key
+
+# Telegram API ID и HASH
+TG_API_UID=123456
+TG_API_HASH=your_tg_api_hash
+
+
+# Важные ID
+ADMIN_ID=123456789
+MODERATOR_ID=987654321
+IMPORTANT_ID=1122334455
+IMPORTANT_GROUP_ID=-1001122334455
+IMPORTANT_CHANNEL_ID=-1009988776655
+
+
+# Настройки бота
+PROJECT_NAME=PRIMO
+BOT_NAME=Первозданная Жемчужина
+BOT_DESCRIPTION=Ваш помощник в удивительные миры! Prod. by:『@verdise』
+BOT_SHORT_DESCRIPTION=Тех.поддержка: @verdise
+
+# Настройки ролевого проекта
+RP_NAME: str = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥"
+
+
+# Права администратора
+ANONYMOUS=False
+MANAGE_CHAT=True
+CHANGE_INFO=True
+PROMOTE_MEMBERS=True
+RESTRICT_MEMBERS=True
+POST_MESSAGE=True
+MANAGE_TOPICS=True
+INVITE_USER=True
+DELETE_MESSAGES=True
+MANAGE_VIDEO_CHATS=True
+EDIT_MESSAGES=True
+PIN_MESSAGE=True
+POST_STORIES=True
+EDIT_STORIES=True
+DELETE_STORIES=True
+
+
+# Поддержка
+SUPPORT_CHAT_ID=0
diff --git a/main.py b/main.py
index dd434b2..8d0dcc5 100644
--- a/main.py
+++ b/main.py
@@ -1,30 +1,25 @@
# main.py
-from aiogram import Bot, Dispatcher
-from aiogram.client.default import DefaultBotProperties
-from BotCode.config import BOT_TOKEN, BOT_DEBUG_TOKEN, DEBUG_MODE, PARSE_MODE
+# Основной код проекта, который и соединяет в себе все его возможности
-dp: Dispatcher = Dispatcher()
-TOKEN: str = BOT_DEBUG_TOKEN if DEBUG_MODE else BOT_TOKEN
-bot: Bot = Bot(
- token=TOKEN,
- default=DefaultBotProperties(
- parse_mode=PARSE_MODE,
- link_preview_show_above_text=True,
- )
-)
+from asyncio import run
+from middleware.loggers import setup_logging
+from bot import *
async def main() -> None:
- from aiogram.types import User
- from BotCode.loggers import logs
- from BotCode.handlers import router as main_router
+ """Входная точка проекта. Запуск бота."""
+ # Запуск логирования
+ setup_logging()
- bot_info: User = await bot.get_me()
- logs.start(text=f"Бот @{bot_info.username} запущен!")
+ # Получение информации о боте
+ await BotInfo.setup(bot)
- dp.include_router(main_router)
+ # Подключение главного маршрутизатора
+ dp.include_router(router)
+ # Включение опроса бота
await dp.start_polling(bot)
+
+# Вечная загрузка бота
if __name__ == "__main__":
- from asyncio import run
run(main())
diff --git a/middleware/loggers/__init__.py b/middleware/loggers/__init__.py
new file mode 100644
index 0000000..d668488
--- /dev/null
+++ b/middleware/loggers/__init__.py
@@ -0,0 +1 @@
+from .logs import *
diff --git a/middleware/loggers/logs.py b/middleware/loggers/logs.py
new file mode 100644
index 0000000..fd7e92d
--- /dev/null
+++ b/middleware/loggers/logs.py
@@ -0,0 +1,234 @@
+from sys import stderr
+from pathlib import Path
+from functools import wraps
+from inspect import iscoroutinefunction
+from typing import Any, Callable, Optional, TypeVar, cast, Final
+
+from loguru import logger
+from aiogram.types import Message, User
+
+from configs.config import BotEdit, LogConfig
+
+# Экспортируемые объекты
+__all__ = ('Logger', 'setup_logging', 'loggers', 'log',)
+
+# Универсальный тип для функций
+F: TypeVar = TypeVar('F', bound=Callable[..., Any])
+
+
+class Logger:
+ """
+ Кастомный логгер с поддержкой декораторов и прямого вызова.
+
+ Attributes:
+ system_name: Имя системы для логирования
+ _log_format: Формат логов
+ """
+ _log_format: Final[str] = (
+ 'inline-код\nблок кода\nссылка\n
текст", - "image": "https://img4.teletype.in/files/f2/47/f247b03d-6197-419a-86c6-10a20c12b2f7.png", - "private": false, - "buttons": [ - [ - { - "text": "Кнопка заглушка", - "url": "http://void" - } - ], - [ - { - "text": "Уведомление", - "callback_data": "bt_TestButton_0", - "show_alert": true, - "notification": "Для вас!" - }, - { - "text": "Увед", - "callback_data": "bt_TestButton_1", - "show_alert": false, - "notification": "Нет! Не для вас!" - } - ], - [ - { - "text": "Кнопка ссылка", - "url": "http://google.com" - } - ], - [ - { - "text": "Копирование", - "copy_text": "Копирование текста!" - } - ], - [ - { - "text": "Для только одного!", - "callback_data": "bt_TestButton_2", - "show_alert": true, - "notification": "только для босса)", - "allowed_ids": [ - 6751720805 - ], - "unauthorized_message": "Вы не босс!" - } - ], - [ - { - "text": "Инлайн 0", - "switch_inline_query": "" - } - ], - [ - { - "text": "инлайн1", - "switch_inline_query": "ЗАПРОСИЩЕ" - }, - { - "text": "инлайн 2", - "switch_inline_query_current_chat": "ЧАТОВАЯ" - }, - { - "text": "инлайн 3", - "switch_inline_query_chosen_chat": "чего?" - } - ] - ] - } -} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 3d044a1..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[project] -name = "primostorybot" -version = "1.4" -description = "Бот для отправки постов с кнопками и разметкой сообщений" -authors = [ - {name = "Verum",email = "sergeyzavalin@outlook.com"} -] -license = {text = "None"} -readme = "README.md" -requires-python = ">=3.10,<4.0" -dependencies = [ - "aiogram (>=3.20.0.post0,<4.0.0)", - "loguru (>=0.7.3,<0.8.0)", - "dotenv (>=0.9.9,<0.10.0)" -] - -[build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eed688c Binary files /dev/null and b/requirements.txt differ