From 519751802926d88b89a68be4f08896d4a8a471d2 Mon Sep 17 00:00:00 2001 From: Niken Date: Sat, 4 Oct 2025 18:56:50 +0300 Subject: [PATCH] I add command /id and /dowmp3 for dowload video with Youtube and i improve code. It's version 0.2.0 --- addons/__init__.py | 0 addons/example_addon/__init__.py | 7 + addons/example_addon/dowloadmp3_to_youtube.py | 158 ++++++++++++++++++ addons/example_addon/handlers.py | 81 +++++++++ addons/id/__init__.py | 7 + addons/id/handlers.py | 13 ++ addons/manager.py | 53 ++++++ bot/core.py | 8 +- handlers/admin.py | 144 ++++++++-------- services/watcher_service.py | 14 +- 10 files changed, 400 insertions(+), 85 deletions(-) create mode 100644 addons/__init__.py create mode 100644 addons/example_addon/__init__.py create mode 100644 addons/example_addon/dowloadmp3_to_youtube.py create mode 100644 addons/example_addon/handlers.py create mode 100644 addons/id/__init__.py create mode 100644 addons/id/handlers.py create mode 100644 addons/manager.py diff --git a/addons/__init__.py b/addons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/addons/example_addon/__init__.py b/addons/example_addon/__init__.py new file mode 100644 index 0000000..8a3fbb5 --- /dev/null +++ b/addons/example_addon/__init__.py @@ -0,0 +1,7 @@ +def register(dp, state, bot): + from . import handlers + handlers.register_handlers(dp, state, bot) + +def unregister(dp): + # Здесь можно удалить хендлеры, если нужно + dp.message_handlers.handlers.clear() diff --git a/addons/example_addon/dowloadmp3_to_youtube.py b/addons/example_addon/dowloadmp3_to_youtube.py new file mode 100644 index 0000000..eb5abc3 --- /dev/null +++ b/addons/example_addon/dowloadmp3_to_youtube.py @@ -0,0 +1,158 @@ +import asyncio +import tempfile +import os +import logging +import glob +import json +import requests + +from mutagen.easyid3 import EasyID3 +from mutagen.id3 import ID3, APIC, error + +logger = logging.getLogger(__name__) + + +async def get_video_info(url: str) -> dict: + """Получает информацию о видео через yt-dlp""" + try: + process = await asyncio.create_subprocess_exec( + 'yt-dlp', + '--dump-json', + '--no-playlist', + url, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + if process.returncode == 0: + return json.loads(stdout.decode()) + except Exception as e: + logger.warning(f"Не удалось получить информацию о видео: {e}") + return None + + +async def download_thumbnail(thumbnail_url: str) -> tuple[bytes, str]: + """Скачивает обложку видео и возвращает данные и MIME тип""" + try: + response = requests.get(thumbnail_url, timeout=10) + if response.status_code == 200: + if 'jpeg' in thumbnail_url or 'jpg' in thumbnail_url: + mime_type = 'image/jpeg' + elif 'png' in thumbnail_url: + mime_type = 'image/png' + else: + mime_type = response.headers.get('Content-Type', 'image/jpeg') + return response.content, mime_type + except Exception as e: + logger.warning(f"Не удалось скачать обложку: {e}") + return None, None + + +def apply_metadata(mp3_path: str, metadata: dict): + """Прописывает ID3-теги и обложку в MP3""" + try: + try: + audio = EasyID3(mp3_path) + except error: + audio = EasyID3() + audio.save(mp3_path) + + audio['title'] = metadata.get('title', 'Unknown Title') + audio['artist'] = metadata.get('performer', 'Unknown Artist') + audio.save(mp3_path) + + if metadata.get('thumbnail_data'): + audio = ID3(mp3_path) + audio.add( + APIC( + encoding=3, + mime=metadata.get('thumbnail_mime', 'image/jpeg'), + type=3, # front cover + desc='Cover', + data=metadata['thumbnail_data'] + ) + ) + audio.save(mp3_path) + logger.info("Обложка добавлена в MP3") + except Exception as e: + logger.warning(f"Не удалось прописать метаданные: {e}") + + +async def download_mp3_isolated(url: str) -> tuple[str, dict]: + """Скачивает MP3, добавляет метаданные и возвращает путь к файлу и метаданные""" + + with tempfile.TemporaryDirectory() as temp_dir: + output_template = os.path.join(temp_dir, "audio.%(ext)s") + + try: + logger.info(f"Запускаю yt-dlp для: {url}") + + video_info = await get_video_info(url) + title = "Unknown Title" + uploader = "Unknown Artist" + thumbnail_url = None + duration = 0 + + if video_info: + title = video_info.get('title', 'Unknown Title') + uploader = video_info.get('uploader', 'Unknown Artist') + thumbnail_url = video_info.get('thumbnail') + if not thumbnail_url and video_info.get('thumbnails'): + thumbnails = video_info.get('thumbnails', []) + if thumbnails: + thumbnail_url = thumbnails[-1].get('url') + duration = video_info.get('duration', 0) + logger.info(f"Получена информация о видео: {title}") + + process = await asyncio.create_subprocess_exec( + 'yt-dlp', + '-x', '--audio-format', 'mp3', + '--audio-quality', '320K', + '--no-playlist', + '-o', output_template, + '--ignore-errors', + url, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300) + + mp3_files = glob.glob(os.path.join(temp_dir, "*.mp3")) + if mp3_files: + actual_file = mp3_files[0] + if os.path.getsize(actual_file) > 1000: + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as final_file: + final_filename = final_file.name + + with open(actual_file, 'rb') as src, open(final_filename, 'wb') as dst: + dst.write(src.read()) + + thumbnail_data, mime_type = None, None + if thumbnail_url: + thumbnail_data, mime_type = await download_thumbnail(thumbnail_url) + if thumbnail_data: + logger.info(f"Обложка скачана: {thumbnail_url}") + + metadata = { + 'title': title, + 'performer': uploader, + 'duration': duration, + 'thumbnail_data': thumbnail_data, + 'thumbnail_mime': mime_type + } + + # Прописываем теги в MP3 + apply_metadata(final_filename, metadata) + + logger.info(f"Успешно скачан и обновлён тегами: {final_filename}") + return final_filename, metadata + + error_msg = stderr.decode() or stdout.decode() or "Файл не создан" + raise Exception(f"Ошибка загрузки: {error_msg}") + + except asyncio.TimeoutError: + raise Exception("Таймаут загрузки (5 минут)") + except Exception as e: + raise e diff --git a/addons/example_addon/handlers.py b/addons/example_addon/handlers.py new file mode 100644 index 0000000..4c227ed --- /dev/null +++ b/addons/example_addon/handlers.py @@ -0,0 +1,81 @@ +from aiogram import types, Dispatcher, Bot +from aiogram.filters import Command +from models.state import BotState +from utils.antispam import admin_required +from logging import getLogger +from .dowloadmp3_to_youtube import * +from os import path, unlink + +logger = getLogger(__name__) + +def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): + @dp.message(Command("dowmp3")) + @admin_required(5) + async def cmd_dowmp3(message: types.Message): + args = message.text.split(maxsplit=1) + if len(args) < 2: + await message.reply("❌ Укажи ссылку: /dowmp3 ") + return + + url = args[1] + logger.info(f"Получена команда /dowmp3 от user_id={message.from_user.id}, url={url}") + + status_msg = await message.reply("⏳ Скачиваю аудио... Это займет 1-2 минуты") + + try: + filename, metadata = await download_mp3_isolated(url) + file_size = path.getsize(filename) + + if file_size < 1000: + raise Exception("Файл слишком маленький") + + await status_msg.edit_text(f"✅ Аудио готово! Отправляю...") + + # Подготавливаем аудио файл + audio_input = types.FSInputFile(filename) + + # Базовые параметры + send_params = { + 'audio': audio_input, + 'title': metadata['title'][:64], + 'performer': metadata['performer'][:64], + 'duration': int(metadata['duration']) if metadata['duration'] else None, + 'caption': f"🎵 {metadata['title']}\n👤 {metadata['performer']}" + } + + # Добавляем обложку если есть + if metadata['thumbnail_data']: + try: + # Создаем временный файл для обложки + with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as thumb_file: + thumb_filename = thumb_file.name + thumb_file.write(metadata['thumbnail_data']) + + # Используем FSInputFile для обложки + send_params['thumbnail'] = types.FSInputFile(thumb_filename) + logger.info("Обложка добавлена к сообщению") + + except Exception as e: + logger.warning(f"Не удалось добавить обложку: {e}") + + # Отправляем аудио + await message.answer_audio(**send_params) + + # Удаляем временный файл обложки если создавали + if 'thumb_filename' in locals() and path.exists(thumb_filename): + unlink(thumb_filename) + + await status_msg.delete() + logger.info(f"Аудио отправлено пользователю {message.from_user.id}") + + except asyncio.TimeoutError: + await status_msg.edit_text("❌ Превышено время ожидания (5 минут)") + except Exception as e: + await status_msg.edit_text(f"❌ Ошибка: {str(e)}") + logger.error(f"Ошибка при скачивании: {e}") + finally: + if 'filename' in locals() and path.exists(filename): + try: + unlink(filename) + except: + pass \ No newline at end of file diff --git a/addons/id/__init__.py b/addons/id/__init__.py new file mode 100644 index 0000000..8a3fbb5 --- /dev/null +++ b/addons/id/__init__.py @@ -0,0 +1,7 @@ +def register(dp, state, bot): + from . import handlers + handlers.register_handlers(dp, state, bot) + +def unregister(dp): + # Здесь можно удалить хендлеры, если нужно + dp.message_handlers.handlers.clear() diff --git a/addons/id/handlers.py b/addons/id/handlers.py new file mode 100644 index 0000000..4138088 --- /dev/null +++ b/addons/id/handlers.py @@ -0,0 +1,13 @@ +from logging import getLogger +from aiogram import Dispatcher, Bot +from aiogram.types import Message +from aiogram.filters import Command +from models.state import BotState + +logger = getLogger(__name__) + +def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): + @dp.message(Command("id")) + async def id(message: Message): + id = message.from_user.id + await message.reply(str(id)) diff --git a/addons/manager.py b/addons/manager.py new file mode 100644 index 0000000..81c5b0e --- /dev/null +++ b/addons/manager.py @@ -0,0 +1,53 @@ +import importlib +import sys +from pathlib import Path + +class AddonManager: + def __init__(self, dp, state, bot): + self.dp = dp + self.state = state + self.bot = bot + self.loaded = {} + + def load(self, name: str): + """Загрузить аддон по имени""" + if name in self.loaded: + return f"Аддон {name} уже загружен" + + module_path = f"addons.{name}" + module = importlib.import_module(module_path) + if hasattr(module, "register"): + module.register(self.dp, self.state, self.bot) + self.loaded[name] = module + return f"✅ Аддон {name} подключен" + return f"⚠️ У аддона {name} нет функции register" + + def unload(self, name: str): + """Отключить аддон""" + module = self.loaded.get(name) + if not module: + return f"Аддон {name} не загружен" + + if hasattr(module, "unregister"): + module.unregister(self.dp) + + # Удаляем из sys.modules, чтобы можно было перезагрузить + sys.modules.pop(f"addons.{name}", None) + self.loaded.pop(name) + return f"❌ Аддон {name} отключен" + + def reload(self, name: str): + """Перезагрузить аддон""" + self.unload(name) + return self.load(name) + + def list_addons(self): + """Возвращает список (имя, состояние) для всех аддонов""" + addons_path = Path("addons") + result = [] + for addon in addons_path.iterdir(): + if addon.is_dir() and (addon / "__init__.py").exists(): + name = addon.name + status = "✅ Загружен" if name in self.loaded else "❌ Выключен" + result.append((name, status)) + return result diff --git a/bot/core.py b/bot/core.py index 93d7e4a..9219b24 100644 --- a/bot/core.py +++ b/bot/core.py @@ -1,6 +1,7 @@ from aiogram import Bot, Dispatcher from config import Config from models.state import BotState +from addons.manager import AddonManager class TelegramBot: @@ -8,17 +9,22 @@ class TelegramBot: self.bot = Bot(token=Config.API_TOKEN) self.dp = Dispatcher() self.state = BotState() + self.addons = AddonManager(self.dp, self.state, self.bot) def setup_handlers(self): """Регистрация всех обработчиков""" from handlers import admin, schedule#, media, common # Регистрируем обработчики из разных модулей - admin.register_handlers(self.dp, self.state, self.bot) + admin.AdminHandlers.register(self) schedule.register_handlers(self.dp, self.state, self.bot) #media.register_handlers(self.dp, self.state, self.bot) #common.register_handlers(self.dp, self.state, self.bot) + #add addons + self.addons.load("example_addon") + self.addons.load("id") + async def start(self): """Запуск бота""" self.setup_handlers() diff --git a/handlers/admin.py b/handlers/admin.py index f83441a..56bbde7 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -1,7 +1,6 @@ -from aiogram import types, Dispatcher, Bot +from aiogram import types from aiogram.types import Message from aiogram.filters import Command -from models.state import BotState from config import Config from utils.antispam import admin_required from services.watcher_service import WatcherService @@ -10,87 +9,80 @@ from logging import getLogger logger = getLogger(__name__) -def register_handlers(dp: Dispatcher, state: BotState, bot: Bot): - @dp.message(Command("log")) - @admin_required(3) - async def send_log(message: Message): - """Отправка логов""" - try: - log_file = types.FSInputFile(Config.LOG_FILE) - await message.answer_document(log_file, caption="📑 Логи бота") - except FileNotFoundError: - await message.answer("Файл логов пока не создан.") - @dp.message(Command("status")) - @admin_required(3) - async def send_status(message: Message): - """Статус бота""" - from utils.analytics import analyze_bot_logs - from utils.mac_metrics import get_macbook_battery_level +class AdminHandlers: - try: - stats = analyze_bot_logs(Config.LOG_FILE) - batt = await get_macbook_battery_level() + def register(self): + """Регистрирует все хендлеры этого класса""" - status_text = ( - "🤖 СТАТУС БОТА\n" - "══════════════\n" - f"✅ Uptime: {stats.get('uptime_percentage', 0)}%\n" - f"⏱️ Слежка расписания: {'ВКЛ' if state.watcher_work else 'ВЫКЛ'}\n" - f"🔋 Уровень заряда: {batt}%" - ) - - await message.answer(status_text) - except Exception as e: - await message.answer(f"❌ Ошибка при проверке статуса: {str(e)}") - - @dp.message(Command("del")) - @admin_required(1) - async def delete_all_messages(message: Message): - """Удаляет все сохраненные сообщения бота""" - messages = load_messages() - if not messages: - sent = await message.answer( - "📭 Нет сохранённых сообщений для удаления.", - reply_to_message_id=message.message_id - ) - save_message(sent.chat.id, sent.message_id) - return - - deleted = 0 - for chat_id, msg_id in messages: + @self.dp.message(Command("log")) + @admin_required(3) + async def send_log(message: Message): try: - await bot.delete_message(chat_id, msg_id) - deleted += 1 + log_file = types.FSInputFile(Config.LOG_FILE) + await message.answer_document(log_file, caption="📑 Логи бота") + except FileNotFoundError: + await message.answer("Файл логов пока не создан.") + + @self.dp.message(Command("status")) + @admin_required(3) + async def send_status(message: Message): + from utils.analytics import analyze_bot_logs + from utils.mac_metrics import get_macbook_battery_level + try: + stats = analyze_bot_logs(Config.LOG_FILE) + batt = await get_macbook_battery_level() + status_text = ( + "🤖 СТАТУС БОТА\n" + "══════════════\n" + f"✅ Uptime: {stats.get('uptime_percentage', 0)}%\n" + f"⏱️ Слежка расписания: {'ВКЛ' if self.state.watcher_work else 'ВЫКЛ'}\n" + f"🔋 Уровень заряда: {batt}%" + ) + await message.answer(status_text) except Exception as e: - logger.warning(f"Не удалось удалить {msg_id} в чате {chat_id}: {e}") + await message.answer(f"❌ Ошибка при проверке статуса: {str(e)}") - clear_messages() + @self.dp.message(Command("del")) + @admin_required(1) + async def delete_all_messages(message: Message): + messages = load_messages() + if not messages: + sent = await message.answer("📭 Нет сохранённых сообщений для удаления.", + reply_to_message_id=message.message_id) + save_message(sent.chat.id, sent.message_id) + return - sent = await message.answer( - f"✅ Удалено {deleted} сообщений (включая /rasp).", - reply_to_message_id=message.message_id - ) - save_message(sent.chat.id, sent.message_id) + deleted = 0 + for chat_id, msg_id in messages: + try: + await self.bot.delete_message(chat_id, msg_id) + deleted += 1 + except Exception as e: + logger.warning(f"Не удалось удалить {msg_id} в чате {chat_id}: {e}") - @dp.message(Command("power")) - @admin_required(2) - async def power_control(message: types.Message): - """Управление слежкой""" - args = message.text.split() - if len(args) < 2: - status = "включена" if state.watcher_work else "выключена" - await message.answer(f"⏱️ Слежка расписания: {status}") - return + clear_messages() + sent = await message.answer(f"✅ Удалено {deleted} сообщений (включая /rasp).", + reply_to_message_id=message.message_id) + save_message(sent.chat.id, sent.message_id) - command = args[1].lower() - watcher_service = WatcherService(state, bot) + @self.dp.message(Command("power")) + @admin_required(2) + async def power_control(message: types.Message): + args = message.text.split() + if len(args) < 2: + status = "включена" if self.state.watcher_work else "выключена" + await message.answer(f"⏱️ Слежка расписания: {status}") + return - if command == "on" and not state.watcher_work: - await watcher_service.start() - await message.answer("✅ Слежка расписания включена") - elif command == "off" and state.watcher_work: - await watcher_service.stop() - await message.answer("❌ Слежка расписания выключена") - else: - await message.answer("❌ Неверная команда") \ No newline at end of file + command = args[1].lower() + watcher_service = WatcherService(self.state, self.bot) + + if command == "on" and not self.state.watcher_work: + await watcher_service.start() + await message.answer("✅ Слежка расписания включена") + elif command == "off" and self.state.watcher_work: + await watcher_service.stop() + await message.answer("❌ Слежка расписания выключена") + else: + await message.answer("❌ Неверная команда") diff --git a/services/watcher_service.py b/services/watcher_service.py index 51e9c63..174e7af 100644 --- a/services/watcher_service.py +++ b/services/watcher_service.py @@ -1,5 +1,4 @@ import asyncio -import hashlib from datetime import datetime, timedelta from random import randint from aiogram import Bot @@ -47,7 +46,7 @@ class WatcherService: while self.state.watcher_work: try: await self._check_all_groups() - delay = randint(600, 700) + delay = randint(Config.WATCHER_BASE_DELAY, Config.WATCHER_BASE_DELAY + 100) await asyncio.sleep(delay) except asyncio.CancelledError: break @@ -55,7 +54,8 @@ class WatcherService: logger.error(f"Ошибка в watcher_loop: {e}") await asyncio.sleep(60) - def _get_target_day(self) -> datetime: + @staticmethod + def _get_target_day() -> datetime: """Получение целевого дня""" now = datetime.now() target = now + timedelta(days=1) @@ -69,14 +69,12 @@ class WatcherService: day = self._get_target_day() for group, chat_id in Config.GROUP_CHATS.items(): - await self._check_group_schedule(group, chat_id, day) + logger.info(f"Проверяем расписание для {group} на {day.strftime('%d.%m.%Y')}") + await self._check_group_schedule(group, chat_id, day.day) async def _check_group_schedule(self, group: str, chat_id: int, day: int): """Проверка расписания для конкретной группы""" - - logger.info(f"Проверяем расписание для {group} на {day.strftime('%d.%m.%Y')}") - - clip_png, url, data_day, data_mouth = await self.schedule_service.get_schedule(group, day.day) + clip_png, url, data_day, data_mouth = await self.schedule_service.get_schedule(group, day) if clip_png: msg = await self.bot.send_photo( chat_id,