commit 0b3b957c0af054b8c18f70229699afc88b3f55a7 Author: Whyverum Date: Tue May 20 09:12:05 2025 +0700 Версия 1.0 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f90d979 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# .dockerignore: Исключения для Docker сборки +# Игнорировать всё, кроме необходимого для production + +**/.git +**/.gitignore +**/.dockerignore +**/Dockerfile +**/README.md + +# Директории +**/__pycache__ +**/.mypy_cache +**/.pytest_cache +**/.idea +**/.vscode +**/test +**/tests +**/docs +**/examples + +# Файлы +**/*.pyc +**/*.pyo +**/*.pyd +**/*.egg-info +**/*.log +**/*.logs +**/*.sqlite +**/*.db +config/.env +**/docker-compose* + +# Артефакты сборки +**/build +**/dist +**/node_modules diff --git a/.env b/.env new file mode 100644 index 0000000..e22aefa --- /dev/null +++ b/.env @@ -0,0 +1,10 @@ +BOT_TOKEN=7694271285:AAEp9AbA72NRPNIJShDfvcL34awHD67Uvug +BOT_DEBUG_TOKEN=7403842222:AAGUFZEQiICZhsvRHSzjHhQp8YXqKb8jL6I + +ADMIN_ID=[6751720805,1686743480,1979597550,1191474440] + +PARSE_MODE=HTML +POST_DIR=posts + +LOGGING_TO_CONSOLE=False +DEBUG_MODE=False diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f96c285 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,75 @@ +# .gitattributes +# === Git LFS (для больших бинарных файлов, ИИ-моделей, архивов и т.п.) === +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text + +# === Конец строки и автоопределение текста === +* text=auto eol=lf + +# === Текстовые файлы (принудительно) === +*.html text +*.css text +*.js text +*.json text +*.md text +*.yml text +*.yaml text +*.xml text +*.txt text + +# === Изображения === +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.bmp binary +*.webp binary +*.ico binary +*.svg text # SVG можно диффить + +# === Шрифты === +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary +*.otf binary + +# === GitHub Linguist (для языка проекта на GitHub) === +*.html linguist-language=HTML +*.css linguist-language=CSS +*.js linguist-language=JavaScript +*.json linguist-language=JSON +*.md linguist-language=Markdown diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c7d07d --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# .gitignore: Игнорируемые файлы для Python проектов +# Подробнее: https://github.com/github/gitignore/blob/main/Python.gitignore + +### Python ### +# Виртуальные окружения и настройки +config/.env +../../../../Desktop/PostBot/.venv +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# Кэш интерпретатора +__pycache__/ +*.py[cod] +*$py.class + +# Пакеты и сборки +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.eggs + +# Poetry +poetry.lock +.pypoetry/ + +### Логи и БД ### +*.log +*.logs +*.log.* +*.logs.* +log/ +logs/ +*.sqlite +*.db + +### IDE ### +.idea/ +.vscode/ +*.swp +*.sublime-* + +### OS ### +.DS_Store +Thumbs.db + +### Тестирование ### +.coverage +htmlcov/ +.tox/ +.nox/ +.pytest_cache/ +.mypy_cache/ diff --git a/.idea/PRIMOSTORYFINAL.iml b/.idea/PRIMOSTORYFINAL.iml new file mode 100644 index 0000000..0685815 --- /dev/null +++ b/.idea/PRIMOSTORYFINAL.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2a39176 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..a8f81ca --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1747702717100 + + + + + + + + + \ No newline at end of file diff --git a/BotCode/__init__.py b/BotCode/__init__.py new file mode 100644 index 0000000..8e4a6d3 --- /dev/null +++ b/BotCode/__init__.py @@ -0,0 +1,4 @@ +from .config import * +from .handlers import * +from .utils import * +from .config import * diff --git a/BotCode/config.py b/BotCode/config.py new file mode 100644 index 0000000..152f4b8 --- /dev/null +++ b/BotCode/config.py @@ -0,0 +1,19 @@ +# BotCode/config.py +from os import getenv +from ast import literal_eval +from dotenv import load_dotenv + +# Загружаем переменные из файла .env +load_dotenv() + +BOT_TOKEN: str|None = getenv('BOT_TOKEN', None) +BOT_DEBUG_TOKEN: str|None = getenv('BOT_DEBUG_TOKEN', None) + +ADMIN_ID: tuple[int] = literal_eval(getenv('ADMIN_ID', '[6751720805]')) + +PARSE_MODE: str = getenv('PARSE_MODE', "HTML") + +LOGGING_TO_CONSOLE: bool = getenv('LOGGING_TO_CONSOLE', "False").lower() == 'true' +DEBUG_MODE: bool = getenv('DEBUG_MODE', "False").lower() == 'true' + +POSTS_DIR: str = getenv('POSTS_DIR', "posts") diff --git a/BotCode/core/__init__.py b/BotCode/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/BotCode/core/storage.py b/BotCode/core/storage.py new file mode 100644 index 0000000..771169e --- /dev/null +++ b/BotCode/core/storage.py @@ -0,0 +1,234 @@ +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 + + +class PostStorage: + """Класс для управления хранением постов и связанных уведомлений.""" + + def __init__(self, posts_dir: str = POSTS_DIR): + self.posts_dir = posts_dir + self.global_posts: Dict[str, Dict[str, Any]] = {} + self.notifications: Dict[str, Dict[str, Any]] = {} + self.alert_texts: Dict[str, Dict[str, Any]] = {} + + self._ensure_posts_dir() + self.load_all_posts() + + def _ensure_posts_dir(self, directory: Optional[str] = None) -> None: + """Создаёт директорию для хранения постов, если она не существует.""" + dir_path = directory or self.posts_dir + if not path.isdir(dir_path): + makedirs(dir_path, exist_ok=True) + logs.info( + f"Created posts directory: {dir_path}", + log_type="STORAGE", + ) + + def _get_user_posts_file(self, user_id: int) -> str: + """Возвращает путь к файлу с постами пользователя.""" + return path.join(self.posts_dir, f"posts_{user_id}.json") + + def _update_button_notifications(self, callback_data: str, notification_data: Dict[str, Any]) -> None: + """Регистрирует данные уведомления кнопки во внутренних хранилищах.""" + if not callback_data: + return + self.alert_texts[callback_data] = notification_data + self.notifications[callback_data] = notification_data + + def _process_buttons(self, post_id: str, buttons: List[Any]) -> None: + """ + Обрабатывает кнопки поста, нормализует callback_data и регистрирует уведомления. + Поддерживает различные типы кнопок: callback, url, copy, inline. + """ + if not buttons: + return + + for row_idx, row in enumerate(buttons): + btns = row if isinstance(row, list) else [row] + for col_idx, button in enumerate(btns): + if not isinstance(button, dict): + continue + + if 'callback_data' in button: + cb_data = button['callback_data'] + if not cb_data or not (cb_data.startswith('bt_') or cb_data.startswith('show_alert_')): + prefix = 'show_alert_' if button.get('show_alert') else 'bt_' + button['callback_data'] = f"{prefix}{post_id}_{row_idx}_{col_idx}" + cb_data = button['callback_data'] + + if 'notification' in button: + notification = { + 'text': button['notification'], + 'show_alert': button.get('show_alert', False), + 'allowed_ids': button.get('allowed_ids'), + 'unauthorized_message': button.get('unauthorized_message') + } + self._update_button_notifications(cb_data, notification) + logs.debug( + f"Registered notification for {cb_data}", + log_type="STORAGE", + ) + + def load_user_posts(self, user_id: int) -> Dict[str, Any]: + """Загружает посты пользователя из файла.""" + file_path = self._get_user_posts_file(user_id) + try: + if path.isfile(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + posts = json.load(f) + if isinstance(posts, dict): + return posts + logs.warning( + f"Invalid posts format in {file_path}", + log_type="STORAGE", + ) + except json.JSONDecodeError as e: + logs.error( + f"JSON decode error in {file_path}: {str(e)}", + log_type="STORAGE", + ) + except Exception as e: + logs.error( + f"Error loading posts from {file_path}: {str(e)}", + log_type="STORAGE", + ) + return {} + + def save_user_posts(self, user_id: int, posts: Dict[str, Any]) -> None: + """ + Сохраняет посты пользователя в файл и обновляет внутренние хранилища. + Обрабатывает кнопки и уведомления перед сохранением. + """ + if not isinstance(posts, dict): + logs.error( + "Invalid posts format, expected dict", + log_type="STORAGE", + ) + return + + for post_id, post in posts.items(): + if isinstance(post, dict) and 'buttons' in post: + self._process_buttons(post_id, post['buttons']) + + file_path = self._get_user_posts_file(user_id) + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(posts, f, ensure_ascii=False, indent=4) + logs.info( + f"Saved posts for user {user_id}", + log_type="STORAGE", + ) + except Exception as e: + logs.error( + f"Error saving posts to {file_path}: {str(e)}", + log_type="STORAGE", + ) + return + + # Обновление кэша: перезагружаем записи этого пользователя + # Удаляем старые записи + for pid in list(self.global_posts): + if pid in posts: + self.global_posts.pop(pid, None) + # Загружаем свежие + fresh = self.load_user_posts(user_id) + for pid, post in fresh.items(): + if isinstance(post, dict) and 'buttons' in post: + self._process_buttons(pid, post['buttons']) + self.global_posts[pid] = post + + def delete_user_post(self, user_id: int, post_id: str) -> bool: + """Удаляет пост пользователя и связанные уведомления. Возвращает статус операции.""" + user_posts = self.load_user_posts(user_id) + if post_id not in user_posts: + logs.warning( + f"Post {post_id} not found for user {user_id}", + log_type="STORAGE", + ) + return False + + post = user_posts.pop(post_id) + notification_count = 0 + if isinstance(post.get('buttons'), list): + for row in post['buttons']: + btns = row if isinstance(row, list) else [row] + for button in btns: + if isinstance(button, dict): + cb = button.get('callback_data') + if cb and cb in self.alert_texts: + self.alert_texts.pop(cb) + self.notifications.pop(cb, None) + notification_count += 1 + logs.debug( + f"Removed {notification_count} notifications for post {post_id}", + log_type="STORAGE", + ) + + # Сохраняем и обновляем кэш + self.save_user_posts(user_id, user_posts) + self.global_posts.pop(post_id, None) + logs.info( + f"Deleted post {post_id} for user {user_id}", + log_type="STORAGE", + ) + return True + + def is_post_available(self, post_id: str) -> bool: + """Проверяет доступность идентификатора поста.""" + return post_id not in self.global_posts + + def load_all_posts(self) -> None: + """Загружает все посты из файлов в рабочей директории.""" + self.global_posts.clear() + self.alert_texts.clear() + self.notifications.clear() + + self._ensure_posts_dir() + loaded_files = 0 + loaded_posts = 0 + + try: + for filename in listdir(self.posts_dir): + if filename.endswith('.json'): + user_id_str = filename[len('posts_'):-len('.json')] + try: + user_id = int(user_id_str) + except ValueError: + logs.warning( + f"Invalid filename format: {filename}", + log_type="STORAGE", + ) + continue + + posts = self.load_user_posts(user_id) + for pid, post in posts.items(): + if isinstance(post, dict) and 'buttons' in post: + self._process_buttons(pid, post['buttons']) + self.global_posts[pid] = post + loaded_posts += 1 + loaded_files += 1 + except Exception as e: + logs.error( + f"Error loading all posts: {str(e)}", + log_type="STORAGE", + ) + + logs.info( + f"Loaded {loaded_posts} posts from {loaded_files} files", + log_type="STORAGE", + ) + + def get_post(self, post_id: str) -> Optional[Dict[str, Any]]: + """Возвращает пост по идентификатору или None если не найден.""" + return self.global_posts.get(post_id) + + def get_notification(self, callback_data: str) -> Optional[Dict[str, Any]]: + """Возвращает данные уведомления для указанного callback.""" + return self.notifications.get(callback_data) + + +# Инициализация хранилища при импорте модуля +storage = PostStorage() diff --git a/BotCode/handlers/__init__.py b/BotCode/handlers/__init__.py new file mode 100644 index 0000000..87ad758 --- /dev/null +++ b/BotCode/handlers/__init__.py @@ -0,0 +1,16 @@ +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__) + +# Include routers with different priorities +router.include_routers( +cmd_routers, + callback_router, + post_routers, + inline_router +) diff --git a/BotCode/handlers/callback.py b/BotCode/handlers/callback.py new file mode 100644 index 0000000..ae5f596 --- /dev/null +++ b/BotCode/handlers/callback.py @@ -0,0 +1,48 @@ +# BotCode/handlers/callback.py +from aiogram import Router, F +from aiogram.types import CallbackQuery +from BotCode.core.storage import storage + +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 + + # Получаем уведомление через хранилище + notif = storage.get_notification(key) + if not notif: + await callback_query.answer() + return + + # Проверяем права доступа + allowed = notif.get("allowed_ids") + if allowed and user_id not in allowed: + msg = notif.get("unauthorized_message", "У вас нет доступа к этому уведомлению.") + await callback_query.answer(text=msg, show_alert=True) + return + + text = notif.get("text", "") + show_alert = notif.get("show_alert", False) + + 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 + + +@router.callback_query(F.data == "void") +async def handle_void_callback(callback_query: CallbackQuery) -> None: + """ + Обработка пустых callback-запросов (void). + Просто отвечает на callback без уведомления. + """ + try: + await callback_query.answer() + except Exception as e: + return diff --git a/BotCode/handlers/commands/__init__.py b/BotCode/handlers/commands/__init__.py new file mode 100644 index 0000000..76bfde9 --- /dev/null +++ b/BotCode/handlers/commands/__init__.py @@ -0,0 +1,7 @@ +from aiogram import Router +from .start_cmd import router as start_cmd_router + +__all__ = ('router',) +router = Router(name="post_router") + +router.include_routers(start_cmd_router,) diff --git a/BotCode/handlers/commands/start_cmd.py b/BotCode/handlers/commands/start_cmd.py new file mode 100644 index 0000000..825a4ea --- /dev/null +++ b/BotCode/handlers/commands/start_cmd.py @@ -0,0 +1,36 @@ +# BotCode/handlers/commands/start_cmd.py +from aiogram import Router, types +from aiogram.filters import CommandStart + +router = Router(name=__name__) +__all__ = ("router",) + + +@router.message(CommandStart()) +async def start_cmd(message: types.Message) -> None: + """ + Обработчик команды /start. + + :param message: Объект сообщения и информации о нем. + :return: Вывод сообщения для администратора, о выборе режимов работы. + """ + from BotCode.loggers import logs + from BotCode.utils import textmd2 + logs.info(text="использовал(а) команду /start", log_type="Start", message=message) + + if message.from_user.id: + # Создаем клавиатурный билдер + from aiogram.utils.keyboard import ReplyKeyboardBuilder + rkb: ReplyKeyboardBuilder = ReplyKeyboardBuilder() + rkb.row(types.KeyboardButton(text="Создать пост📔")) + rkb.row(types.KeyboardButton(text="Посмотреть список📋")) + + # Отправка фотографии с текстом и клавиатурой + from aiogram.types.input_file import FSInputFile + await message.reply_photo( + photo=FSInputFile('assets/start.jpg'), + caption=textmd2("Добро пожаловать в систему, Босс!"), + reply_markup=rkb.as_markup(resize_keyboard=True) + ) + else: + await message.reply(text=textmd2("Простите, вы не мой Босс!❌\nОбратитесь к @verdise!")) diff --git a/BotCode/handlers/inline.py b/BotCode/handlers/inline.py new file mode 100644 index 0000000..83686a4 --- /dev/null +++ b/BotCode/handlers/inline.py @@ -0,0 +1,179 @@ +# BotCode/handlers/inline.py +from aiogram import Router +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + InlineQuery, + InputTextMessageContent, + InlineQueryResultArticle, + SwitchInlineQueryChosenChat, + CopyTextButton, +) +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 + +router = Router(name="inline_send") + + + +def build_markup(buttons_def: list[list[dict]]) -> InlineKeyboardMarkup | None: + """ + Создаёт InlineKeyboardMarkup из списка описаний кнопок. + Поддерживает URL, callback, inline-моды. + Обрабатывает "void"-кнопки как callback_data="void". + Для switch_inline_query_chosen_chat устанавливает хотя бы один allow_* True. + """ + if not buttons_def: + return None + + rows: list[list[InlineKeyboardButton]] = [] + for row_idx, row in enumerate(buttons_def): + if not isinstance(row, list): + logs.warning(f"Некорректный формат ряда кнопок: {row}") + continue + + kb_row: list[InlineKeyboardButton] = [] + for col_idx, b in enumerate(row): + if not isinstance(b, dict): + logs.warning(f"Некорректный формат кнопки в ряду {row_idx}: {b}") + continue + + text = b.get("text", "") + if not text: + logs.warning(f"Пустой текст кнопки в ряду {row_idx}, колонке {col_idx}") + continue + + btn = None + try: + if "url" in b: + url = b["url"] + if url.lower().endswith("void"): + btn = InlineKeyboardButton(text=text, callback_data="void") + else: + btn = InlineKeyboardButton(text=text, url=url) + elif "switch_inline_query" in b: + btn = InlineKeyboardButton( + text=text, + switch_inline_query=b["switch_inline_query"] + ) + elif "switch_inline_query_current_chat" in b: + btn = InlineKeyboardButton( + text=text, + switch_inline_query_current_chat=b["switch_inline_query_current_chat"] + ) + elif "switch_inline_query_chosen_chat" in b: + query = b["switch_inline_query_chosen_chat"] + if isinstance(query, dict): + siqcc = SwitchInlineQueryChosenChat( + query=query.get("query", ""), + allow_user_chats=query.get("allow_user_chats", True), + allow_group_chats=query.get("allow_group_chats", True), + allow_channel_chats=query.get("allow_channel_chats", True), + allow_bot_chats=query.get("allow_bot_chats", False), + ) + else: + siqcc = SwitchInlineQueryChosenChat( + query=query, + allow_user_chats=True, + allow_group_chats=True, + allow_channel_chats=True, + allow_bot_chats=False, + ) + btn = InlineKeyboardButton( + text=text, + switch_inline_query_chosen_chat=siqcc + ) + elif "copy_text" in b: + btn = InlineKeyboardButton( + text=text, + copy_text=CopyTextButton(text=b["copy_text"]) + ) + elif "callback_data" in b: + btn = InlineKeyboardButton( + text=text, + callback_data=b["callback_data"] + ) + except Exception as e: + logs.error(f"Ошибка при создании кнопки в ряду {row_idx}, колонке {col_idx}: {e}") + continue + + if btn: + kb_row.append(btn) + + if kb_row: + rows.append(kb_row) + + if not rows: + return None + + return InlineKeyboardMarkup(inline_keyboard=rows) + + +@router.inline_query() +async def inline_query_handler(inline_query: InlineQuery): + """ + Обрабатывает инлайн-запросы для поиска и отправки постов. + Фильтрует посты по приватности и поисковому запросу. + """ + # Перезагружаем все посты из файлов на случай изменений + storage.load_all_posts() + + query = inline_query.query or "" + user_id = inline_query.from_user.id + username = inline_query.from_user.username or f"user_{user_id}" + + logs.debug(f"Получен инлайн-запрос от {username} (ID: {user_id}): {query}") + + results = [] + for post_id, post in storage.global_posts.items(): + try: + # Проверка приватности + if post.get("private") and post.get("user_id") != user_id: + continue + + # Проверка поискового запроса + if query and query.lower() not in post_id.lower(): + continue + + # Тело сообщения + text = textmd2(post.get("text", "")) + image = post.get("image", "") + if image and image.startswith("http"): + text = f"{hide_link(image)}{text}" + + # Клавиатура + markup = build_markup(post.get("buttons", [])) + + results.append( + InlineQueryResultArticle( + id=post_id, + 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 + ), + reply_markup=markup + ) + ) + except Exception as e: + logs.error(f"Ошибка при обработке поста {post_id}: {e}") + continue + + logs.info(f"Отправлено {len(results)} результатов для запроса '{query}' от {username} (ID: {user_id})") + + try: + await inline_query.answer(results, cache_time=0, is_personal=True) + except Exception as e: + logs.error(f"Ошибка при отправке результатов инлайн-запроса: {e}") + + +__all__ = [ + 'router', + 'inline_query_handler' +] \ No newline at end of file diff --git a/BotCode/handlers/post/__init__.py b/BotCode/handlers/post/__init__.py new file mode 100644 index 0000000..f111393 --- /dev/null +++ b/BotCode/handlers/post/__init__.py @@ -0,0 +1,10 @@ +from aiogram import Router +from .create_posts import router as posts_router +from .post_list import router as post_list_router + +router = Router(name="post_router") + +router.include_routers( +posts_router, + post_list_router, +) diff --git a/BotCode/handlers/post/create_posts.py b/BotCode/handlers/post/create_posts.py new file mode 100644 index 0000000..727495f --- /dev/null +++ b/BotCode/handlers/post/create_posts.py @@ -0,0 +1,210 @@ +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 BotCode.core.storage import storage +from BotCode.utils import textmd2 + +router = Router() + +class PostState(StatesGroup): + waiting_for_text = State() + waiting_for_privacy = State() + waiting_for_id = State() + waiting_for_image = State() + waiting_for_buttons = State() + +post_id_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) -> list[list[dict]]: + rows: list[list[dict]] = [] + current: list[dict] = [] + for raw in text.splitlines(): + line = raw.strip() + if not line: + if current: + rows.append(current) + current = [] + continue + if '|' not in line: + raise ValueError(f"Неверный формат кнопки: '{line}'") + label, action = map(str.strip, line.split('|', 1)) + btn: dict = {"text": label} + if action.startswith('notification:'): + btn['notification'] = action.split(':', 1)[1] + btn['show_alert'] = True + elif action.startswith('copy:'): + btn['callback_data'] = f"copy_{uuid.uuid4().hex}" + btn['copy_text'] = action.split(':', 1)[1] + elif action.startswith('switch_inline:'): + btn['switch_inline_query'] = action.split(':', 1)[1] + elif action.startswith('switch_inline_current:'): + btn['switch_inline_query_current_chat'] = action.split(':', 1)[1] + elif action.startswith('switch_inline_chosen:'): + btn['switch_inline_query_chosen_chat'] = action.split(':', 1)[1] + elif action.startswith(('http://', 'https://')): + btn['url'] = action + else: + btn['callback_data'] = action + current.append(btn) + if current: + rows.append(current) + return rows + +# --- Handlers --- +@router.message(F.text == "Создать пост📔") +async def start_creation(message: Message, state: FSMContext): + await state.set_state(PostState.waiting_for_text) + await state.update_data(private=False, buttons=[]) + await message.reply( + textmd2( + """Отправьте текст вашего поста: +Тест для проверки @userbotname +Жирный +Курсив +Подчёркнутый +Зачёркнутый +Моноширинный +
Предварительно отформатированный
+Ссылка +""" + ), + 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/handlers/post/post_list.py b/BotCode/handlers/post/post_list.py new file mode 100644 index 0000000..6e0ab77 --- /dev/null +++ b/BotCode/handlers/post/post_list.py @@ -0,0 +1,200 @@ +from math import ceil +from aiogram import Router, F +from aiogram.types import ( + Message, CallbackQuery, + InlineKeyboardButton, InlineKeyboardMarkup, + SwitchInlineQueryChosenChat, CopyTextButton +) +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 + +router = Router(name="posts_manager") + +PAGE_SIZE = 5 + +async def send_posts_list( + message: Message = None, + callback_query: CallbackQuery = None, + page: int = 0 +) -> None: + """Отправляет список постов пользователя с пагинацией.""" + user_id = message.from_user.id if message else callback_query.from_user.id + posts = storage.load_user_posts(user_id) + + if not posts: + msg = "Нет сохранённых постов." + if message: + await message.answer(msg) + else: + await callback_query.answer(msg, show_alert=True) + return + + post_ids = list(posts.keys()) + total = len(post_ids) + pages = ceil(total / PAGE_SIZE) + page = max(0, min(page, pages - 1)) + + start = page * PAGE_SIZE + end = start + PAGE_SIZE + current_ids = post_ids[start:end] + + rows: list[list[InlineKeyboardButton]] = [] + for pid in current_ids: + post = posts[pid] + priv = "🔒" if post.get("private") else "🔓" + btn = InlineKeyboardButton( + text=f"{priv} Пост {pid}", + callback_data=f"view_post_{pid}" + ) + rows.append([btn]) + + # Пагинация + nav_buttons = create_pagination_buttons( + action="open_post_list", + page=page, + total_posts=total, + bt_page=PAGE_SIZE + ) + if nav_buttons: + rows.append(nav_buttons) + + # Кнопка закрытия + rows.append([InlineKeyboardButton(text="Закрыть❌", callback_data="cancel_list")]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + header = "Список ваших постов:" + + try: + if callback_query: + await callback_query.message.edit_text(header, reply_markup=keyboard) + else: + await message.answer(header, reply_markup=keyboard) + except TelegramBadRequest: + if callback_query: + await callback_query.message.delete() + await callback_query.message.answer(header, reply_markup=keyboard) + else: + await message.answer(header, reply_markup=keyboard) + +# --- Хендлеры списка --- +@router.message(F.text.lower() == "посмотреть список📋") +async def cmd_list(message: Message): + await send_posts_list(message=message) + +@router.callback_query(F.data == "open_post_list") +async def cb_open_list(cq: CallbackQuery): + 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): + try: + page = int(cq.data.rsplit("_", 1)[-1]) + except ValueError: + await cq.answer("Некорректная страница", show_alert=True) + return + await send_posts_list(callback_query=cq, page=page) + await cq.answer() + +@router.callback_query(F.data == "cancel_list") +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_")) +async def view_post_callback(cq: CallbackQuery): + pid = cq.data.replace("view_post_", "") + uid = cq.from_user.id + posts = storage.load_user_posts(uid) + if pid not in posts: + await cq.answer("Пост не найден", show_alert=True) + return + + post = posts[pid] + text = textmd2(post.get("text", "")) + img = post.get("image", "") + if img.startswith("http"): + text = f"{hide_link(img)}{text}" + + rows: list[list[InlineKeyboardButton]] = [] + for row in post.get("buttons", []): + btns: list[InlineKeyboardButton] = [] + for b in row: + if "copy_text" in b: + btns.append( + InlineKeyboardButton( + text=b["text"], + copy_text=CopyTextButton(text=b["copy_text"]) + ) + ) + elif "switch_inline_query" in b: + btns.append( + InlineKeyboardButton( + text=b["text"], + switch_inline_query=b["switch_inline_query"] + ) + ) + elif "switch_inline_query_current_chat" in b: + btns.append( + InlineKeyboardButton( + text=b["text"], + switch_inline_query_current_chat=b["switch_inline_query_current_chat"] + ) + ) + elif "switch_inline_query_chosen_chat" in b: + raw = b["switch_inline_query_chosen_chat"] + cfg = raw if isinstance(raw, dict) else { + "query": raw, + "allow_user_chats": True + } + btns.append( + InlineKeyboardButton( + text=b["text"], + switch_inline_query_chosen_chat=SwitchInlineQueryChosenChat(**cfg) + ) + ) + elif "url" in b: + url = b["url"] + if url.lower().endswith("void"): + btns.append( + InlineKeyboardButton(text=b["text"], callback_data="void") + ) + else: + btns.append( + InlineKeyboardButton(text=b["text"], url=url) + ) + elif "callback_data" in b: + btns.append( + InlineKeyboardButton(text=b["text"], callback_data=b["callback_data"]) + ) + if btns: + rows.append(btns) + + # Удалить / назад + rows.append([ + InlineKeyboardButton(text="Удалить❌", callback_data=f"delete_post_{pid}"), + InlineKeyboardButton(text="Назад◀️", callback_data="open_post_list") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + + await cq.message.answer(text=text, reply_markup=keyboard, parse_mode=PARSE_MODE) + 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): + pid = cq.data.replace("delete_post_", "") + uid = cq.from_user.id + if storage.delete_user_post(uid, pid): + await cq.answer(f"Пост {pid} удалён") + await send_posts_list(callback_query=cq) + else: + await cq.answer("Не удалось удалить пост", show_alert=True) diff --git a/BotCode/loggers/__init__.py b/BotCode/loggers/__init__.py new file mode 100644 index 0000000..2e432b8 --- /dev/null +++ b/BotCode/loggers/__init__.py @@ -0,0 +1,5 @@ +# BotLibrary/loggers/__init__.py +# Инициализация модуля loggers, для настройки логеров + +# Экспортирование модулей во внешние слои проекта +from .logs import * diff --git a/BotCode/loggers/logs.py b/BotCode/loggers/logs.py new file mode 100644 index 0000000..14d4fc8 --- /dev/null +++ b/BotCode/loggers/logs.py @@ -0,0 +1,147 @@ +""" +Модуль логирования для Telegram-бота. + +Особенности: +* Вывод логов в консоль и/или файл +* Автоматическая ротация и удержание +* Форматирование с информацией о системе, типе события и пользователе +* Удобные методы для разных уровней логирования +""" + +from sys import stderr +from pathlib import Path +from typing import Optional, Final, Union + +from loguru import logger +from aiogram.types import Message, User + +try: + from config import LogConfig +except ImportError: + class LogConfig: + """Запасные настройки логирования, если config недоступен.""" + CONSOLE: Final[bool] = True + FILE: Final[bool] = True + DIR: Final[Path] = Path('Logs') + ROTATION: Final[str] = '100 MB' + RETENTION: Final[str] = '7 days' + +# Настройка экспорта в модули +__all__ = ['Logs', 'logs'] + + +class Logs: + """ + Класс для работы с логированием через loguru. + """ + _SYSTEM_NAME: Final[str] = 'PRIMO' # Исправлено: убран обратный слэш + _LOG_FORMAT: Final[str] = ( + '{time:YYYY-MM-DD HH:mm:ss.SSS} | ' # Исправлено форматирование времени + '{extra[system]}-{extra[log_type]} | ' + '{extra[user]} | {message}' + ) + + @staticmethod + def _format_user(message: Optional[Message]) -> str: + """ + Форматирует информацию о пользователе из сообщения. + """ + if not message or not message.from_user: + return '@System' + user: User = message.from_user + return f"@{user.username}" if user.username else f"id{user.id}" + + @classmethod + def _log(cls, + level: Union[str, int], + text: str, + log_type: str, + message: Optional[Message] = None) -> None: + """Внутренний метод логирования.""" + user_ctx = cls._format_user(message) + logger.bind( + system=cls._SYSTEM_NAME, + user=user_ctx, + log_type=log_type, + ).log(level, text) + + @classmethod + def setup(cls, start: bool = True) -> None: + """Инициализация логирования: консоль и/или файл.""" + logger.remove() + + # Консольный вывод + if getattr(LogConfig, 'CONSOLE', False): + logger.add( + stderr, + format=cls._LOG_FORMAT, + colorize=True, + level='DEBUG', + filter=lambda rec: rec['extra'].get('log_type') != 'DEBUG' + ) + + # Файловый вывод с ротацией + if getattr(LogConfig, 'FILE', False): + log_dir = getattr(LogConfig, 'DIR', Path('logs')) + log_dir.mkdir(parents=True, exist_ok=True) + logger.add( + log_dir / 'bot.log', + rotation=getattr(LogConfig, 'ROTATION', '100 MB'), + retention=getattr(LogConfig, 'RETENTION', '7 days'), + format=cls._LOG_FORMAT, + level='DEBUG', + enqueue=True, + backtrace=True, + diagnose=True + ) + + # Добавляем вызов start() если нужно + if start: + cls.start() + + @classmethod + def start(cls, text: str = 'Запуск бота...', log_type: str = 'START') -> None: + """Логирование старта приложения.""" + cls._log(level='INFO', text=text, log_type=log_type) + + @classmethod + def debug(cls, + text: str, + log_type: str = 'DEBUG', + message: Optional[Message] = None) -> None: + cls._log(level='DEBUG', text=text, log_type=log_type, message=message) + + @classmethod + def info(cls, + text: str, + log_type: str = 'INFO', + message: Optional[Message] = None) -> None: + cls._log(level='INFO', text=text, log_type=log_type, message=message) + + @classmethod + def warning(cls, + text: str, + log_type: str = 'WARNING', + message: Optional[Message] = None) -> None: + cls._log(level='WARNING', text=text, log_type=log_type, message=message) + + @classmethod + def error(cls, + text: str, + log_type: str = 'ERROR', + message: Optional[Message] = None) -> None: + cls._log(level='ERROR', text=text, log_type=log_type, message=message) + + @classmethod + def exception(cls, + text: str, + exception: Exception, + log_type: str = 'EXCEPTION', + message: Optional[Message] = None) -> None: + full_text = f"{text}\nException: {exception!r}" + cls._log(level='ERROR', text=full_text, log_type=log_type, message=message) + + +# Инициализация экземпляра логгера +logs = Logs() +logs.setup() diff --git a/BotCode/utils/__init__.py b/BotCode/utils/__init__.py new file mode 100644 index 0000000..c74a0a3 --- /dev/null +++ b/BotCode/utils/__init__.py @@ -0,0 +1,3 @@ +from .md2_escape import * +from .usernames import * +from .pagination import * diff --git a/BotCode/utils/md2_escape.py b/BotCode/utils/md2_escape.py new file mode 100644 index 0000000..65e8ac2 --- /dev/null +++ b/BotCode/utils/md2_escape.py @@ -0,0 +1,36 @@ +# BotCode/utils/md2_escape.py +from BotCode.config import PARSE_MODE + +# Настройка экспорта в модули +__all__ = ("textmd2",) + +def textmd2(msg: str, + parse_mode: str = PARSE_MODE, + special_chars: str = r"_*[]()~`>#+-=|{}.!") -> str: + """ + Экранирует специальные символы MarkdownV2 в переданном тексте. + + :param msg: Входной текст в виде строки. + :param parse_mode: Формат форматирования ('MarkdownV2' или 'HTML'). + :param special_chars: Символы, которые необходимо экранировать. + + :return: Экранированный текст или исходный текст, если формат HTML. + :raises TypeError: Если передан не строковый тип данных. + :raises ValueError: Если parse_mode задан некорректно. + """ + from re import sub, escape + + if not isinstance(msg, str): + raise TypeError(f"Ожидается строка, но получено {type(msg).__name__}") + + if not isinstance(parse_mode, str): + raise TypeError(f"parse_mode должен быть строкой, но получено {type(parse_mode).__name__}") + + if parse_mode.strip().lower() == "html": + return msg + + elif parse_mode in {"markdownv2", "markdown"}: + return sub(rf"([{escape(special_chars)}])", r"\\\1", msg) + + else: + raise ValueError(f"Недопустимое значение parse_mode: '{parse_mode}'. Ожидалось 'HTML' или 'MarkdownV2'") diff --git a/BotCode/utils/pagination.py b/BotCode/utils/pagination.py new file mode 100644 index 0000000..c139cb0 --- /dev/null +++ b/BotCode/utils/pagination.py @@ -0,0 +1,22 @@ +# 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/BotCode/utils/usernames.py b/BotCode/utils/usernames.py new file mode 100644 index 0000000..bfba6ae --- /dev/null +++ b/BotCode/utils/usernames.py @@ -0,0 +1,22 @@ +# BotCode/utils/username.py +from aiogram.types import Message + +# Настройка экспорта в модули +__all__ = ('username', ) + +# Функция получения юзера или ID пользователя +def username(message: Message) -> str: + """ + Возвращает юзернейм пользователя из сообщения, или ID, если юзернейм не указан. + + :param message: Объект сообщения из aiogram. + :return: Строка с юзернеймом пользователя или его ID. + :raises ValueError: Если в сообщении отсутствует информация о пользователе. + """ + try: + if message.from_user: + return f"@{message.from_user.username}" if message.from_user.username else f"@{message.from_user.id}" + raise ValueError("Информация о пользователе отсутствует в сообщении.") + + except ValueError as e: + raise e # Перебрасываем ошибку выше для дальнейшей обработки diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc7d007 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM mwalbeck/python-poetry:2.1-3.11 + +WORKDIR /PostBot + +COPY pyproject.toml poetry.lock ./ +RUN poetry install --no-interaction --no-root --only main + +COPY ../../../../Desktop/PostBot . + +CMD ["poetry", "run", "python", "-m", "main"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..46329e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2025] [Лейн] + +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, +subject to the following conditions: + +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 +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7da29b9 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Создание поста +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 diff --git a/assets/start.jpg b/assets/start.jpg new file mode 100644 index 0000000..41fea9f Binary files /dev/null and b/assets/start.jpg differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..dd434b2 --- /dev/null +++ b/main.py @@ -0,0 +1,30 @@ +# 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, + ) +) + +async def main() -> None: + from aiogram.types import User + from BotCode.loggers import logs + from BotCode.handlers import router as main_router + + bot_info: User = await bot.get_me() + logs.start(text=f"Бот @{bot_info.username} запущен!") + + dp.include_router(main_router) + + await dp.start_polling(bot) + +if __name__ == "__main__": + from asyncio import run + run(main()) diff --git a/posts/posts_6751720805.json b/posts/posts_6751720805.json new file mode 100644 index 0000000..653a358 --- /dev/null +++ b/posts/posts_6751720805.json @@ -0,0 +1,291 @@ +{ + "anketa_dottore_butterfly": { + "user_id": 6751720805, + "text": "┏━━━━━━━━━━━━━━━━━━━━┓\n Вы сидите в кабинете управляющего и ждёте его возвращения. Время тянется бесконечно долго. Вам всё больше кажется, что ожидание длится целую вечность. \nРешив немного развеяться, вы начинаете прохаживаться по кабинету и замечаете, что персональный компьютер управляющего включён. Подойдя ближе, вы понимаете, что он забыл его выключить. \nПовиляв мышкой, вы видите, как на рабочем столе появляется изображение — картина с портретом владельца этого устройства.\n※─────────────【₪】─────────────※\n Добро пожаловать, Дотторе.\n — ..\n — Досье №67517...\n — teminаl\n — experiment_X01.tar\n — wallpaper.png\n※─────────────【₪】─────────────※ \n Кажется, вы проверили все файлы... Нужно поскорее прибраться, пока не вернулся управляющий. Вряд ли он обрадуется такому \"подарку\".\n┗━━━━━━━━━━━━━━━━━━━━┛", + "image": "https://img4.teletype.in/files/3f/b9/3fb9c695-28cb-417c-9577-6fe8fb7e6fe1.jpeg", + "private": true, + "buttons": [ + [ + { + "text": "Прочитать досье📋", + "url": "https://teletype.in/@whyverum/DottoreMagicButterflies" + } + ], + [ + { + "text": "Рабочий стол🖼", + "callback_data": "show_alert_0", + "show_alert": true, + "notification": "Подпись внутри файла: \"Я помню кем ты был. Не изменись..\"" + } + ], + [ + { + "text": "Эксперимент👀", + "callback_data": "show_alert_1", + "show_alert": false, + "notification": "Никто. Не должен. Знать об этом." + } + ] + ] + }, + "dottore_butterfly_post1": { + "user_id": 6751720805, + "text": "╔══════════════════════╗\n \n ◯ ⃝ꦿДотторе: Творец бытия 𖧷۪۪‌⃟ꦽ⃟\n\n╰─────────────────────╮ \n╚══════════════════════╝\n〇 ° ੦ ੦ ੦ ° ੦ ᅠᅠᅠᅠ\n♾️ Дᴧя ʍᴇня ᴄущᴇᴄᴛʙуᴇᴛ дʙᴇ ʙᴏɜʍᴏжнᴏᴄᴛи: ᴧибᴏ дᴏбиᴛьᴄя ᴨᴏᴧнᴏᴦᴏ ᴏᴄущᴇᴄᴛʙᴧᴇния ᴄʙᴏих ᴨᴧᴀнᴏʙ, ᴧибᴏ ᴨᴏᴛᴇᴩᴨᴇᴛь нᴇудᴀчу. Дᴏбьюᴄь — ᴄᴛᴀну ᴏдниʍ иɜ ʙᴇᴧичᴀйɯих ʙ иᴄᴛᴏᴩии, ᴨᴏᴛᴇᴩᴨᴧю нᴇудᴀчу — буду ᴏᴄуждᴇн, ᴏᴛʙᴇᴩᴦнуᴛ и ᴨᴩᴏᴋᴧяᴛ.\n\n 〇 Ѵαɗε αʈ Ѵεɾʝʈʈαʈεϻ! 〇 ᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠ \n ┏━━━━━━━━━━━━━━━━━━━┓\n || Дотторе; Фурина; Наблюдатель; ???;\n┊□࿔°⊹—– Новые встречи. Эксперимент №1.\n \"Живые воды\".\n\n ❉ ...Дотторе, резко оглянулся. Теперь вместо уютного кабинета, он видел пустоту, что поглощала остатки его кабинета. Всё, что оставалось, — это стол и старое офисное кресло, на котором тот сидел. Атмосфера всепоглощающей бездны начинала давить на него, но все равно управляющий старался держать себя под контролем. Он медленно встал с кресла и оперся о стол. Смотря вдаль, он начал замечать странный силуэт, чем-то напоминающий ██████████.❉\n\n ⥤ Ха! Значит, это ты, тот кто перенес меня сюда, верно?!\n\n ➢ ...\n\n ⥤ Аномалия? Зараженный? Нет. Ты нечто иное, верно?\n\n ➢ Д̶ͬо̍͢к̱ͪт0̺͞рͤ͡, я̸́ у́͂в̮́_̙̉рͧ͝е̝͒н̖ͤ в̰͘ в̨̂а̅ͯшИ̃̕х͎̋ с̵̙п2̴ͣо̛̥сͯ͡0̦͢б̙ͦн̛̼о̩̐с̦̑т̃Я̼̇х̀̐.\n\n [...] \n\n ❉ Сущность перед Дотторе, будто улыбнулась и рассмеялась после новой партии в шахматы. Оно смотрело прямо на управляющего, будто тот загорелся новой идеей.❉\n\n ⥤ Значит, вот оно что.. Хахах! Как же я сам не догадался об этом! Эфир! Это..!\n\n ➢ П̠͞р̤͘о̗͌щ̷͋а̺͒й͚̚т͎ͥе̛ͨ, Д͚ͬоͭ̚к͂̋т̾͟о̗ͤр̥̇.\n\n\n ❉ Силуэт ██████████ начал растворяться в бесконечной пустоте. Спустя часы или, быть может, дни — собеседник управляющего исчез бесследно. Дотторе с улыбкой, поглаживал свой подбородок, захлестываясь новыми идеями. Но одна мысль не давала ему покоя.. ❉ \n\n ⥤ Как же мне выбраться отсюда? Кхм, а если...\n ┗━━━━━━━━━━━━━━━━━━━┛\n . ·.° ✤╮•.✦╯•╰─✣.·\n • ·.°│.•. . ·°\n ❉°.", + "image": "https://img3.teletype.in/files/6a/53/6a53563b-6f39-4b11-841c-376de0d0cf36.jpeg", + "private": true, + "buttons": [ + [ + { + "text": "Сфокусироваться♾️", + "url": "https://teletype.in/@whyverum/dottore_butterfly_post1" + } + ], + [ + { + "text": "Отчет📋", + "callback_data": "bt_dottore_butterfly_post1_0", + "show_alert": true, + "notification": "Леди Фурина обладает способностями управления водой и создания водяных существ. На данный момент ей присвоен класс D — из-за довольно лёгких условий содержания и быстрого подавления.", + "allowed_ids": [ + 7940956521, + 1987289929, + 1781218883, + 1004666697, + 6751720805, + 5539791027, + 1848629094, + 7670414891, + 1102904738, + 1723370206, + 6718320347 + ], + "unauthorized_message": "🔒 Вы не являетесь официальным сотрудником по приказу S1-2025! Запросите повышение доступа!" + }, + { + "text": "Зов💠", + "callback_data": "bt_dottore_butterfly_post1_1", + "show_alert": true, + "notification": "✡️⚐🕆 👍✌️☠️ 💧❄️✋☹️☹️ 👎✌️☠️👍☜.", + "allowed_ids": [ + 6714603161, + 7483863010, + 1993133001, + 8162774433, + 1010196821, + 785169037, + 481787136, + 2040384869, + 6356873908, + 2046536572, + 6960477141, + 1398573474 + ], + "unauthorized_message": "В̺͓̅ӹ̻̘́ н͉͋̈́е̷̶̥ п̮͘͞о̩̄͡д̫͊͘о̨̱̍й̝ͥ͠д̷̛͆е̵̸͙т̴ͫ̆е̼̌͢." + } + ], + [ + { + "text": "Сдаться⛔️", + "callback_data": "bt_dottore_butterfly_post1_2", + "show_alert": false, + "notification": "Нет, эта история должна, идти по другому пути." + } + ], + [ + { + "text": "Леди Фурине🔖", + "callback_data": "bt_dottore_butterfly_post1_3", + "show_alert": true, + "notification": "И как же вам пост, милая Леди?", + "allowed_ids": [ + 6714603161 + ], + "unauthorized_message": "📶 Загрузка: ■■■■■■■□□□ 70%" + } + ] + ] + }, + "dottore_butterfly_post2": { + "user_id": 6751720805, + "text": "╔══════════════════════╗\n \n ◯ ⃝ꦿДотторе: Творец бытия 𖧷۪۪‌⃟ꦽ⃟\n\n╰─────────────────────╮ \n╚══════════════════════╝\n〇 ° ੦ ੦ ੦ ° ੦ ᅠᅠᅠᅠ\n♾️ Дᴧя ʍᴇня ᴄущᴇᴄᴛʙуᴇᴛ дʙᴇ ʙᴏɜʍᴏжнᴏᴄᴛи: ᴧибᴏ дᴏбиᴛьᴄя ᴨᴏᴧнᴏᴦᴏ ᴏᴄущᴇᴄᴛʙᴧᴇния ᴄʙᴏих ᴨᴧᴀнᴏʙ, ᴧибᴏ ᴨᴏᴛᴇᴩᴨᴇᴛь нᴇудᴀчу. Дᴏбьюᴄь — ᴄᴛᴀну ᴏдниʍ иɜ ʙᴇᴧичᴀйɯих ʙ иᴄᴛᴏᴩии, ᴨᴏᴛᴇᴩᴨᴧю нᴇудᴀчу — буду ᴏᴄуждᴇн, ᴏᴛʙᴇᴩᴦнуᴛ и ᴨᴩᴏᴋᴧяᴛ.\n\n 〇 Ѵαɗε αʈ Ѵεɾʝʈʈαʈεϻ! 〇 ᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠ \n ┏━━━━━━━━━━━━━━━━━━━┓\n || Дотторе; Фурина; Наблюдатель; ???;\n┊□࿔°⊹—– Эксперимент №1.\n \"Живые воды\". Истинное лицо..\n\n ❉ ...То, что вы всё же увидели, начало не просто пугать, а скорее шокировать. Тьма, что всего пару минут назад казалась бесконечно пустой, окрасилась в ярко-кровавый оттенок. Но страннее всего был запах — плотный, всепроникающий, с отчётливыми нотами гари.\nПод ногами вдруг начал вырисовываться горный выступ, с которого тьма стекала вниз, словно по трубе. Нет — скорее, по \"тьмапроводу\". Бессмысленные ассоциации лезли в голову, пытаясь хоть как-то защитить рассудок — безуспешно.\nИ вот, вдалеке, стал виден источник запаха: охваченный огнём город. Оттуда поднимались целые столбы дыма. Со скалы хорошо просматривались пылающие улицы, горящие пятиэтажки и центр города — с полуразрушенной церковью. С неохотой вы решаете спуститься вниз. В Бездне было лишь одно различимое место — город пламени.❉ \n\n ➢ Что здесь произошло?..\n\n ❉ Всё вокруг было объято огнём. Каждый шаг сопровождался треском горящей древесины и далекими, резкими криками. Всё сложнее становилось воспринимать происходящее.\nЧем ближе вы подходили к центру, тем сильнее нарастал внутренний дискомфорт. Единственным кажущимся безопасным местом была церковь — пугающе тихая, но почему-то манящая.\nИ тогда вы начали замечать их. Манекены. Они были повсюду: сидели за столами, стояли за прилавками, их части валялись на дороге, никому не нужные. Всё это напоминало сцены из старых фильмов ужасов...\nЗвон колокола прервал мысли — одинокий, чистый звук, будто разгонявший атмосферу апокалипсиса. И затем...\nМанекены ожили.\nК их телам спустились с неба кровавые нити — теперь они были марионетками. Армия неживых, безликих \"деревянных солдатиков\" медленно, но уверенно начала приближаться.\nИ среди них вы увидели его. Фигура мужчины — белый халат, напоминающий медицинский, маска в стиле чумных докторов XVII века и волосы неестественного лазурного цвета. Он сразу выделялся на фоне остальных.\nОн посмотрел вам прямо в лицо... и рассмеялся. Смех был безумен — словно психопат наслаждался происходящим.❉ \n\n ⥤ Значит, это ты? Ха-ха-ха-ха!\n\n ➢ ...\n\n ⥤ Человек, которому дана возможность переписывать миры? Бог, стоящий передо мной?!\n\n ➢ Ч-что...?\n\n ❉ Доктор взмахнул рукой, указывая пальцем вниз. Манекены за спиной тут же сдавили ваши плечи, опуская на колени. Они не причиняли вреда — просто подчинялись приказу своего полководца.\nДоктор продолжал смеяться, наслаждаясь моментом. Он подошёл ближе, вальяжной походкой, и, наконец, схватил вас за подбородок, подняв голову вверх, заставляя смотреть в глаза.❉ \n\n ⥤ Наблюдатель... Смотри на меня. Я — одно из ваших творений. Так почему на коленях стою не я?\n\n ⥤ Так что ты скажешь теперь?)\n ┗━━━━━━━━━━━━━━━━━━━┛\n . ·.° ✤╮•.✦╯•╰─✣.·\n • ·.°│.•. . ·°\n ❉°", + "image": "https://img3.teletype.in/files/68/61/6861ff76-b7ec-418a-999f-d3522356c97d.jpeg", + "private": true, + "buttons": [ + [ + { + "text": "Si vis pacem, para bellum!⚜️", + "url": "https://teletype.in/@whyverum/dottore_butterfly_post2" + } + ], + [ + { + "text": "Панталоне💳", + "callback_data": "bt_dottore_butterfly_post2_0", + "show_alert": true, + "notification": "11010000 10111101 11010000 10110101", + "allowed_ids": [ + 5539791027, + 7051557370 + ], + "unauthorized_message": "...Уважаемый Панталоне, в связи с тем, что наши исследования берут новый оборот, нам нужно пересчитать бюджет и отправить отчетность о нем в вышестоящие..." + }, + { + "text": "Аль-Хайтам🧰", + "callback_data": "bt_dottore_butterfly_post2_1", + "show_alert": true, + "notification": "11010000 10110010 11010000 10110101 11010001 10000000 11010001 10001100", + "allowed_ids": [ + 1723370206, + 7051557370 + ], + "unauthorized_message": "...смотрителю Аль-Хайтаму, следует явиться как можно скорее в кабинет управляющего Дотторе, для того чтобы провести беседу о его прикрепленных подопытных..." + } + ], + [ + { + "text": "Яэ Мико✉️", + "callback_data": "bt_dottore_butterfly_post2_2", + "show_alert": true, + "notification": "11010001 10000010 11010000 10111110 11010000 10111100 11010001 10000011", + "allowed_ids": [ + 7940956521, + 7051557370 + ], + "unauthorized_message": "...я назначаю вас, заведующим эксперимента \"Вопль-11\", мы должны узнать истину о нашем \"знакомом\"." + }, + { + "text": "Сяо🎭", + "callback_data": "bt_dottore_butterfly_post2_3", + "show_alert": true, + "notification": "11010000 10111010 11010001 10000010 11010000 10111110", + "allowed_ids": [ + 8018592486, + 7051557370 + ], + "unauthorized_message": "Ⲧⲁⲕ ⲧы ⲧⲟⲯⲉ ⲙⲟⲏⲥⲧⲣ?..." + } + ], + [ + { + "text": "Сянь Юнь📝", + "callback_data": "bt_dottore_butterfly_post2_4", + "show_alert": true, + "notification": "11010000 10110011 11010000 10111110 11010000 10110010 11010000 10111110 11010001 10000000 11010000 10111000 11010001 10000010", + "allowed_ids": [ + 7511347907, + 7051557370 + ], + "unauthorized_message": "...недавние медицинские анализы образцов смутили меня. Проведите повторную полную диагностику, особенно над леди Фуриной, у нее замечены определенные проблемы..." + }, + { + "text": "Мидзуки🔑", + "callback_data": "bt_dottore_butterfly_post2_5", + "show_alert": true, + "notification": "11010001 10000000 11010001 10000011 11010000 10111010 11010000 10110000 11010000 10111100 11010000 10111000", + "allowed_ids": [ + 1497624978, + 7051557370 + ], + "unauthorized_message": "...в заключении, передай остальным, что скоро я проверю работу каждого сотрудника. Пусть все будут готовы и исправят свои недочеты..." + } + ], + [ + { + "text": "Я все еще могу танцевать.💠", + "callback_data": "bt_dottore_butterfly_post2_6", + "show_alert": true, + "notification": "Я ⲣⲁⲇ, ⳡⲧⲟ ⲃы ⲡⲟⲏяⲗυ ⲙⲉⲏя. Ⲧⲟⲅⲇⲁ ⲡⲩⲧь ⲏⲁⳡⲏёⲧⲥя ⲥ ⲥⲟⲕⲣыⲧυя. Ⲡⲣυⲕⲣⲟύⲧⲉ ⲡⲁⲗьцⲉⲙ ⳝⲉⲥⲕⲟⲏⲉⳡⲏⲟⲥⲧь — υ ⲃы ⲃⲥё ⲡⲟύⲙёⲧⲉ.", + "allowed_ids": [ + 7483863010, + 1993133001, + 7051557370, + 6714603161, + 8162774433, + 7846830127, + 2051969619, + 2040384869, + 2046536572, + 6960477141, + 6721342628, + 1686805799, + 5991527415 + ], + "unauthorized_message": "Ты дуʍᴀᴇɯь я буду ʙᴇᴩиᴛь,Тᴏʍу, ᴋᴛᴏ ᴏᴛняᴧ их ᴄʙᴏбᴏду?Тᴏʍу, ᴋᴛᴏ ᴄᴛᴏᴧьᴋᴏ ʙᴩᴇʍᴇниВᴇᴩиᴧ ʙ ᴧжиʙыᴇ нᴀʍᴇᴩᴇнья?" + } + ] + ] + }, + "TestButton": { + "user_id": 6751720805, + "text": "Тест для проверки\nЖирный\nКурсив\nПодчёркнутый\nЗачёркнутый\ninline-код\n
блок кода
\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 new file mode 100644 index 0000000..3d044a1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[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"