Compare commits
26 Commits
ccf9db7d20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c0edc77a11 | |||
| bbd9c839d5 | |||
| 9584a99133 | |||
| 629c2c2739 | |||
| 963ce24e4e | |||
| 97a79948a4 | |||
| e24215b9a6 | |||
| 034223a52b | |||
| c49b00ba15 | |||
| 4b50941b86 | |||
| d11c0ee467 | |||
| 7495062a8a | |||
| b6c1c60609 | |||
| be9ec785f4 | |||
| e1837400ef | |||
| 6d421249ee | |||
| e56401bf1d | |||
| a71a7fbd0c | |||
| 7b653d4dcc | |||
| 772d3d5b83 | |||
| cce8c7dc70 | |||
| 3ef1327b67 | |||
| c5f6da31ba | |||
| 5197518029 | |||
| 7702c9a85b | |||
| 58b47bec5e |
@@ -0,0 +1,10 @@
|
|||||||
|
TELEGRAM_BOT_TOKEN=your_token_here
|
||||||
|
|
||||||
|
# Полное отключение логов и любого хранения (БД, файлы логов, сохранение message_id)
|
||||||
|
# DISABLE_PERSISTENCE=1
|
||||||
|
|
||||||
|
# Или по отдельности:
|
||||||
|
# DISABLE_LOGGING=1 — нет логов в консоль и в файл
|
||||||
|
# DISABLE_STORAGE=1 — нет SQLite, /del не работает, группы /set не сохраняются
|
||||||
|
|
||||||
|
SCHEDULE_DRIVE_FOLDER_ID=1WhUFHGkS4qC_e84KRArF4ooXHJr8mL5T
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
/.env
|
||||||
|
/code.txt
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
__pycache__/
|
||||||
|
bot.log
|
||||||
|
bot/__pycache__/
|
||||||
|
handlers/__pycache__/
|
||||||
|
models/__pycache__/
|
||||||
|
services/__pycache__/
|
||||||
|
storage/__pycache__/
|
||||||
|
utils/__pycache__/
|
||||||
|
storage/message.txt
|
||||||
|
/addons/dowloadmp4_to_youtube/gettoken.py
|
||||||
|
/drevo.py
|
||||||
|
/addons/download_mp3_to_youtube/cookies.txt
|
||||||
|
/storage/message.db
|
||||||
|
/addons/todo/todo.db
|
||||||
|
/addons/rule34/urls.db
|
||||||
|
/addons/x_days_to/days_to_new_year.db
|
||||||
|
/addons/hello/photo_2025-11-17_20-57-54.jpg
|
||||||
|
/addons/poll/img.png
|
||||||
|
/addons/hello/мемы/
|
||||||
|
/xaxa.py
|
||||||
|
/addons/hello/img.png
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Все значимые изменения проекта.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
- Добавлена проверка MD5-хеша скриншота в `services/watcher_service.py` — бот теперь отправляет/закрепляет фото только при первом обнаружении и при изменениях.
|
||||||
|
- Исправлен импорт в `addons/x_days_to/__init__.py` (модуль `x_days_to.py` теперь импортируется корректно).
|
||||||
|
- (Замечание) Найдена потенциальная ошибка: `addons/send_message/handlers.py` использует `aiohttp` без явного импорта — нужно поправить.
|
||||||
|
|
||||||
|
## 0.1.0 - WIP
|
||||||
|
- Начальная версия проекта. Планируется приватный релиз после исправлений и добавления CI.
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# MyFirstProgramm
|
||||||
|
|
||||||
|
Лёгкий Telegram-бот на Python (aiogram + Playwright) для автоматической проверки и рассылки расписаний, а также набора аддонов.
|
||||||
|
|
||||||
|
## Краткое описание
|
||||||
|
Проект периодически проверяет расписание на сайте (через Playwright), делает скриншоты нужных фрагментов, вычисляет хеш (MD5) и отправляет/закрепляет изображение в целевых чатах Telegram только при изменении (чтобы не флудить чат). Также в `addons/` есть дополнительные модули (мини-приложения).
|
||||||
|
|
||||||
|
## Структура (важные файлы/папки)
|
||||||
|
- `main.py` — точка входа приложения (запуск бота).
|
||||||
|
- `config.py` — конфигурация (переменные окружения).
|
||||||
|
- `services/` — сервисы: `schedule_service.py`, `watcher_service.py` (логика слежки/хэшей).
|
||||||
|
- `models/state.py` — in-memory состояние бота (кэши, last_clip_hash и т.д.).
|
||||||
|
- `addons/` — набор дополнительных модулей (каждый аддон экспортирует `register` / `unregister`).
|
||||||
|
- `requirements.txt` — зависимости.
|
||||||
|
|
||||||
|
## Быстрый старт (локально)
|
||||||
|
1. Установите зависимости (рекомендуется виртуальное окружение):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Создайте `.env` (или экспортируйте переменные окружения):
|
||||||
|
- `TELEGRAM_BOT_TOKEN` — токен бота
|
||||||
|
- (опционально) `DEEPGRAM_API_KEY`, `ACCESS_TOKEN` — если используются аддоны
|
||||||
|
|
||||||
|
Создайте файл `.env` рядом с `config.py`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
#DEEPGRAM_API_KEY=...
|
||||||
|
#ACCESS_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Запустите бота (пример):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
(В вашем окружении запуск может быть оформлен скриптом — например `./Desktop/my.sh`.)
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
Основные настройки находятся в `config.py`. Обратите внимание, что `Config` сейчас поднимает исключение, если `TELEGRAM_BOT_TOKEN` не задан.
|
||||||
|
|
||||||
|
## Что изменилось (коротко)
|
||||||
|
- Добавлена проверка MD5-хеша скриншота в `services/watcher_service.py` — теперь бот отправляет фото и закрепляет сообщение только при первом обнаружении и при изменениях.
|
||||||
|
- Исправлен импорт в `addons/x_days_to/__init__.py` (модуль назывался `x_days_to.py`, вместо отсутствующего `handlers`).
|
||||||
|
|
||||||
|
(Более полный список изменений — в `CHANGELOG.md`.)
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
telegram_bot/
|
||||||
|
├── main.py # Точка входа
|
||||||
|
├── config.py # Конфигурация
|
||||||
|
├── bot/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── core.py # Основной класс бота
|
||||||
|
│ └── filters.py # Кастомные фильтры
|
||||||
|
├── handlers/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── admin.py # Админ-команды
|
||||||
|
│ ├── schedule.py # Команды расписания
|
||||||
|
│ ├── media.py # Медиа-команды
|
||||||
|
│ └── common.py # Общие команды
|
||||||
|
├── services/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── schedule_service.py # Логика расписания
|
||||||
|
│ ├── watcher_service.py # Слежка за изменениями
|
||||||
|
│ ├── media_service.py # Работа с медиа
|
||||||
|
│ └── gpt_service.py # GPT-функции
|
||||||
|
├── models/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── state.py # Модели данных
|
||||||
|
├── utils/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── antispam.py # Антиспам система
|
||||||
|
│ ├── file_utils.py # Работа с файлами
|
||||||
|
│ └── validators.py # Валидаторы
|
||||||
|
└── storage/
|
||||||
|
├── __init__.py
|
||||||
|
└── message_storage.py # Работа с сообщениями
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import dropbox
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
import unicodedata
|
||||||
|
import uuid
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
# Настройка кодировки для всего приложения
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DROPBOX_TOKEN = Config.Token
|
||||||
|
LIMIT = 2 * 1024 * 1024 * 1024 # 2 ГБ
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_filename(name: str) -> str:
|
||||||
|
"""Создает безопасное имя файла"""
|
||||||
|
# Нормализуем Unicode символы
|
||||||
|
normalized = unicodedata.normalize("NFKD", name)
|
||||||
|
# Убираем акценты и специальные символы, оставляем только ASCII
|
||||||
|
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
||||||
|
# Заменяем проблемные символы
|
||||||
|
safe_name = "".join(
|
||||||
|
c if c.isalnum() or c in (" ", "-", "_", ".") else "_" for c in ascii_name
|
||||||
|
)
|
||||||
|
# Убираем множественные подчеркивания и обрезаем длину
|
||||||
|
safe_name = "_".join(filter(None, safe_name.split("_")))
|
||||||
|
return safe_name[:100] or f"video_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
result = json.loads(stdout.decode("utf-8", errors="ignore"))
|
||||||
|
logger.info(
|
||||||
|
f"Информация о видео получена: {result.get('title', 'Unknown')}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
error_msg = stderr.decode("utf-8", errors="ignore")
|
||||||
|
logger.warning(f"yt-dlp ошибка: {error_msg}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось получить информацию о видео: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def download_mp4_to_dropbox(url: str) -> tuple[str, dict]:
|
||||||
|
"""Скачивает MP4 в среднем качестве БЫСТРО, загружает в Dropbox"""
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_template = os.path.join(temp_dir, "video.%(ext)s")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Запускаю yt-dlp для: {url}")
|
||||||
|
|
||||||
|
video_info = await get_video_info(url)
|
||||||
|
title = "Unknown_Title"
|
||||||
|
uploader = "Unknown_Uploader"
|
||||||
|
duration = 0
|
||||||
|
|
||||||
|
if video_info:
|
||||||
|
title = await safe_filename(video_info.get("title", "Unknown_Title"))
|
||||||
|
uploader = await safe_filename(
|
||||||
|
video_info.get("uploader", "Unknown_Uploader")
|
||||||
|
)
|
||||||
|
duration = video_info.get("duration", 0)
|
||||||
|
logger.info(f"Обработано видео: {title}")
|
||||||
|
|
||||||
|
# ОПТИМИЗИРОВАННЫЕ НАСТРОЙКИ ДЛЯ СКОРОСТИ
|
||||||
|
download_process = await asyncio.create_subprocess_exec(
|
||||||
|
"yt-dlp",
|
||||||
|
"-f",
|
||||||
|
"bestvideo[height<=720][filesize<800M]+bestaudio/best[height<=720][filesize<800M]",
|
||||||
|
"--no-playlist",
|
||||||
|
"-o",
|
||||||
|
output_template,
|
||||||
|
"--ignore-errors",
|
||||||
|
"--no-warnings",
|
||||||
|
"--format-sort",
|
||||||
|
"quality,res:720,size:800M",
|
||||||
|
"--concurrent-fragments",
|
||||||
|
"4",
|
||||||
|
url,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
download_process.communicate(), timeout=600
|
||||||
|
) # Уменьшил таймаут
|
||||||
|
|
||||||
|
# Остальной код без изменений...
|
||||||
|
if download_process.returncode != 0:
|
||||||
|
error_msg = (
|
||||||
|
stderr.decode("utf-8", errors="ignore")
|
||||||
|
if stderr
|
||||||
|
else "Unknown error"
|
||||||
|
)
|
||||||
|
logger.error(f"Ошибка скачивания: {error_msg}")
|
||||||
|
raise Exception(f"Ошибка скачивания: {error_msg}")
|
||||||
|
|
||||||
|
mp4_files = glob.glob(os.path.join(temp_dir, "*.mp4"))
|
||||||
|
if not mp4_files:
|
||||||
|
video_files = glob.glob(os.path.join(temp_dir, "*.*"))
|
||||||
|
video_files = [
|
||||||
|
f
|
||||||
|
for f in video_files
|
||||||
|
if f.lower().endswith((".mp4", ".mkv", ".avi", ".mov", ".webm"))
|
||||||
|
]
|
||||||
|
if video_files:
|
||||||
|
mp4_files = [video_files[0]]
|
||||||
|
|
||||||
|
if mp4_files:
|
||||||
|
actual_file = mp4_files[0]
|
||||||
|
size = os.path.getsize(actual_file)
|
||||||
|
|
||||||
|
if size > LIMIT:
|
||||||
|
raise Exception("Файл превышает лимит 2 ГБ")
|
||||||
|
|
||||||
|
safe_title = await safe_filename(title)
|
||||||
|
final_filename = f"{safe_title}.mp4"
|
||||||
|
|
||||||
|
dbx = dropbox.Dropbox(DROPBOX_TOKEN)
|
||||||
|
dropbox_path = f"/{final_filename}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Загружаем файл в Dropbox: {dropbox_path} (размер: {size / (1024 * 1024):.1f} MB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(actual_file, "rb") as f:
|
||||||
|
file_data = f.read()
|
||||||
|
dbx.files_upload(
|
||||||
|
file_data,
|
||||||
|
dropbox_path,
|
||||||
|
mode=dropbox.files.WriteMode("overwrite"),
|
||||||
|
)
|
||||||
|
|
||||||
|
shared_link = dbx.sharing_create_shared_link_with_settings(dropbox_path)
|
||||||
|
link = shared_link.url.replace("?dl=0", "?dl=1")
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"title": title,
|
||||||
|
"uploader": uploader,
|
||||||
|
"duration": duration,
|
||||||
|
"filesize": size,
|
||||||
|
"quality": "optimized for speed",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Успешно загружено в Dropbox: {link}")
|
||||||
|
return link, metadata
|
||||||
|
|
||||||
|
raise Exception("Видео файл не найден после скачивания")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise Exception("Таймаут загрузки (10 минут)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Общая ошибка: {e}")
|
||||||
|
raise e
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from models.state import BotState
|
||||||
|
|
||||||
|
from .dowmp4 import download_mp4_to_dropbox
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("dowmp4"))
|
||||||
|
# @admin_required(4)
|
||||||
|
async def dowmp4_handler(message):
|
||||||
|
"""Обработчик команды /dowmp4"""
|
||||||
|
try:
|
||||||
|
url = message.text.split(" ", 1)[1].strip()
|
||||||
|
except IndexError:
|
||||||
|
await message.answer("Пожалуйста, укажите URL видео после команды /dowmp4")
|
||||||
|
return
|
||||||
|
|
||||||
|
processing_msg = await message.answer(
|
||||||
|
"⏳ Начинаю обработку видео... Это может занять несколько минут."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Скачиваем и загружаем в Dropbox
|
||||||
|
public_url, metadata = await download_mp4_to_dropbox(url)
|
||||||
|
|
||||||
|
# Форматируем информацию о видео
|
||||||
|
duration_str = f"{int(metadata['duration'] // 60)}:{int(metadata['duration'] % 60):02d}"
|
||||||
|
size_mb = f"{metadata['filesize'] / (1024 * 1024):.1f} MB"
|
||||||
|
|
||||||
|
caption = (
|
||||||
|
f"🎥 **{metadata.get('title', 'Видео')}**\n"
|
||||||
|
f"👤 **Автор:** {metadata.get('uploader', 'Неизвестен')}\n"
|
||||||
|
f"⏱ **Длительность:** {duration_str}\n"
|
||||||
|
f"📦 **Размер:** {size_mb}\n"
|
||||||
|
f"🔗 **Ссылка:** [Скачать]({public_url})"
|
||||||
|
)
|
||||||
|
|
||||||
|
await processing_msg.delete() # Удаляем сообщение о обработке
|
||||||
|
await message.answer(
|
||||||
|
f"✅ **Видео успешно обработано!**\n\n{caption}",
|
||||||
|
parse_mode="Markdown",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обработке /dowmp4: {e}", exc_info=True)
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла ошибка при обработке видео. Попробуйте позже."
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
from mutagen.id3 import ID3, APIC, error
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_video_info(url: str) -> Optional[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:
|
||||||
|
info = json.loads(stdout.decode())
|
||||||
|
logger.info(f"Информация получена: {info.get('title', 'Unknown')}")
|
||||||
|
return info
|
||||||
|
else:
|
||||||
|
stderr_msg = stderr.decode()
|
||||||
|
logger.error(f"yt-dlp ошибка: {stderr_msg}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось получить информацию о видео: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def download_thumbnail(
|
||||||
|
thumbnail_url: str,
|
||||||
|
) -> tuple[Optional[bytes], Optional[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:
|
||||||
|
# Сначала удаляем старые теги ID3 если есть
|
||||||
|
try:
|
||||||
|
ID3(mp3_path).delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось удалить старые ID3 теги: {e}")
|
||||||
|
|
||||||
|
# Добавляем текстовые теги
|
||||||
|
try:
|
||||||
|
audio = EasyID3(mp3_path)
|
||||||
|
except error:
|
||||||
|
audio = EasyID3()
|
||||||
|
|
||||||
|
audio["title"] = metadata.get("title", "Unknown Title")
|
||||||
|
audio["artist"] = metadata.get("performer", "Unknown Artist")
|
||||||
|
audio.save(mp3_path, v2_version=4)
|
||||||
|
logger.info(f"Текстовые теги добавлены: {audio['title']} - {audio['artist']}")
|
||||||
|
|
||||||
|
# Добавляем обложку отдельно
|
||||||
|
if metadata.get("thumbnail_data"):
|
||||||
|
try:
|
||||||
|
audio = ID3(mp3_path)
|
||||||
|
except error:
|
||||||
|
audio = ID3()
|
||||||
|
|
||||||
|
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, v2_version=4)
|
||||||
|
logger.info(f"Обложка добавлена в MP3 ({len(metadata['thumbnail_data'])} байт)")
|
||||||
|
else:
|
||||||
|
logger.warning("Данные обложки не найдены в метаданных")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось прописать метаданные: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
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={title}, uploader={uploader}, thumbnail={thumbnail_url is not None}")
|
||||||
|
else:
|
||||||
|
logger.warning("video_info = None, используются значения по умолчанию")
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.error(f"Ошибка при скачивании: {e}", exc_info=True)
|
||||||
|
raise e
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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 download_mp3_isolated
|
||||||
|
import tempfile
|
||||||
|
import asyncio
|
||||||
|
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 <youtube_url>")
|
||||||
|
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("✅ Аудио готово! Отправляю...")
|
||||||
|
|
||||||
|
# Подготавливаем аудио файл
|
||||||
|
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']}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавляем обложку если есть
|
||||||
|
thumb_filename = None
|
||||||
|
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(f"Обложка подготовлена: {thumb_filename} ({len(metadata['thumbnail_data'])} байт)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось добавить обложку: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Отправляем аудио
|
||||||
|
await message.answer_audio(**send_params)
|
||||||
|
|
||||||
|
# Удаляем временный файл обложки если создавали
|
||||||
|
if thumb_filename and path.exists(thumb_filename):
|
||||||
|
try:
|
||||||
|
unlink(thumb_filename)
|
||||||
|
logger.info("Временный файл обложки удален")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Не удалось удалить файл обложки: {e}")
|
||||||
|
|
||||||
|
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 OSError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from PIL import Image
|
||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message, BufferedInputFile
|
||||||
|
from aiogram.filters import Command
|
||||||
|
|
||||||
|
from models.state import BotState
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
from storage.message_storage import save_message
|
||||||
|
|
||||||
|
from transformers import pipeline
|
||||||
|
|
||||||
|
from utils.antispam import saving
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SD_URL = "http://192.168.31.95:7860/sdapi/v1/txt2img"
|
||||||
|
|
||||||
|
# Загружаем пайплайн перевода один раз при старте (синхронный)
|
||||||
|
translator = pipeline("translation", model="Helsinki-NLP/opus-mt-ru-en")
|
||||||
|
|
||||||
|
|
||||||
|
async def translate_to_en(text: str) -> str:
|
||||||
|
try:
|
||||||
|
# выполняем перевод в отдельном потоке, чтобы не блокировать event loop
|
||||||
|
result = await asyncio.to_thread(translator, text, max_length=512)
|
||||||
|
return result[0]["translation_text"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка перевода: {e}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_img2img(prompt: str, init_image: BytesIO) -> BytesIO | None:
|
||||||
|
"""
|
||||||
|
Генерация изображения по методу img2img.
|
||||||
|
:param prompt: текстовый промт (уже переведённый на английский)
|
||||||
|
:param init_image: входное изображение в BytesIO
|
||||||
|
:return: BytesIO с результатом или None при ошибке
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Определяем размеры оригинала
|
||||||
|
init_image.seek(0)
|
||||||
|
with Image.open(init_image) as img:
|
||||||
|
width, height = img.size
|
||||||
|
|
||||||
|
# кодируем входное изображение в base64
|
||||||
|
init_image_base64 = base64.b64encode(init_image.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"init_images": [init_image_base64],
|
||||||
|
"prompt": prompt,
|
||||||
|
"negative_prompt": "blurry, low quality, bad anatomy, watermark, text, cropped",
|
||||||
|
"steps": 25,
|
||||||
|
"width": width, # берём ширину оригинала
|
||||||
|
"height": height, # берём высоту оригинала
|
||||||
|
"sampler_name": "Euler a",
|
||||||
|
"scheduler": "Karras", # исправлен ключ
|
||||||
|
"cfg_scale": 10,
|
||||||
|
"seed": -1,
|
||||||
|
"denoising_strength": 0.45,
|
||||||
|
"restore_faces": True,
|
||||||
|
"override_settings": {
|
||||||
|
"sd_model_checkpoint": "waiNSFWIllustrious_v150.safetensors"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
SD_URL.replace("txt2img", "img2img"), json=payload
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error(f"Stable Diffusion img2img API error: {resp.status}")
|
||||||
|
return None
|
||||||
|
r = await resp.json()
|
||||||
|
image_base64 = r["images"][0]
|
||||||
|
return BytesIO(base64.b64decode(image_base64))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка img2img: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# sd_xl_base_1.0.safetensors
|
||||||
|
# waiNSFWIllustrious_v150.safetensors
|
||||||
|
async def generate_image(prompt: str) -> BytesIO | None:
|
||||||
|
payload = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"negative_prompt": "blurry, low quality, bad anatomy, watermark, text, cropped",
|
||||||
|
"steps": 20,
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"sampler_name": "Euler a", # сэмплер
|
||||||
|
"cfg_scale": 7, # насколько строго следовать промту
|
||||||
|
"seed": -1, # -1 = случайный сид
|
||||||
|
"batch_size": 1, # сколько картинок за раз
|
||||||
|
"n_iter": 1, # сколько раз повторить генерацию
|
||||||
|
"restore_faces": False, # восстановление лиц
|
||||||
|
"tiling": False, # тайлинг для текстур
|
||||||
|
"enable_hr": False, # highres fix (двухэтапная генерация)
|
||||||
|
"denoising_strength": 0.7, # сила денойзинга (актуально при enable_hr или img2img)
|
||||||
|
"hr_scale": 2, # во сколько раз увеличить при highres fix
|
||||||
|
"hr_upscaler": "Latent", # апскейлер для highres fix
|
||||||
|
"override_settings": {
|
||||||
|
"sd_model_checkpoint": "waiNSFWIllustrious_v150.safetensors" # выбор модели
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(SD_URL, json=payload) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error(f"Stable Diffusion API error: {resp.status}")
|
||||||
|
return None
|
||||||
|
r = await resp.json()
|
||||||
|
image_base64 = r["images"][0]
|
||||||
|
return BytesIO(base64.b64decode(image_base64))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка генерации изображения: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("draw"))
|
||||||
|
async def draw(message: Message):
|
||||||
|
save_message(message.chat.id, message.message_id)
|
||||||
|
if message.from_user.id in Config.BAN:
|
||||||
|
msg = await message.reply("Вы в бане")
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
else:
|
||||||
|
user_prompt = message.text.replace("/draw", "").strip()
|
||||||
|
if not user_prompt:
|
||||||
|
confirm_msg = await message.answer("❗ Укажи промт после команды /draw")
|
||||||
|
save_message(confirm_msg.chat.id, confirm_msg.message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
en_prompt = await translate_to_en(user_prompt)
|
||||||
|
logger.info(f"Промт переведен: {user_prompt} -> {en_prompt}")
|
||||||
|
|
||||||
|
img_bytes = await generate_image(en_prompt)
|
||||||
|
if img_bytes:
|
||||||
|
img_bytes.seek(0)
|
||||||
|
photo = BufferedInputFile(img_bytes.read(), filename="result.png")
|
||||||
|
msg = await bot.send_photo(chat_id=message.chat.id, photo=photo)
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
else:
|
||||||
|
error_msg = await message.answer("⚠️ Ошибка при генерации изображения.")
|
||||||
|
save_message(error_msg.chat.id, error_msg.message_id)
|
||||||
|
|
||||||
|
@dp.message(Command("img2img"))
|
||||||
|
@saving
|
||||||
|
async def img2img_with_caption(message: Message, bot: Bot):
|
||||||
|
raw_caption = message.caption or ""
|
||||||
|
user_prompt = raw_caption.replace("/img2img", "").strip()
|
||||||
|
if not user_prompt:
|
||||||
|
await message.answer(
|
||||||
|
"❗ Укажи промт в подписи к фото после команды /img2img"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
en_prompt = await translate_to_en(user_prompt)
|
||||||
|
logger.info(f"Промт для img2img переведен: {user_prompt} -> {en_prompt}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if message.photo:
|
||||||
|
# Берём последнее (самое большое) фото
|
||||||
|
photo = message.photo[-1].file_id
|
||||||
|
|
||||||
|
# Отправляем в SD API по file_id (как в iadmin)
|
||||||
|
# Здесь отличие: SD API требует base64, поэтому file_id нужно скачать
|
||||||
|
# Но логика построена как в iadmin — сначала берём file_id
|
||||||
|
file = await bot.get_file(photo)
|
||||||
|
file_bytes = await bot.download_file(file.file_path)
|
||||||
|
|
||||||
|
img_bytes = await generate_img2img(
|
||||||
|
en_prompt, BytesIO(file_bytes.read())
|
||||||
|
)
|
||||||
|
if img_bytes:
|
||||||
|
img_bytes.seek(0)
|
||||||
|
photo = BufferedInputFile(
|
||||||
|
img_bytes.read(), filename="img2img_result.png"
|
||||||
|
)
|
||||||
|
msg = await bot.send_photo(chat_id=message.chat.id, photo=photo)
|
||||||
|
else:
|
||||||
|
msg = await message.answer("⚠️ Ошибка при img2img генерации.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
msg = await message.answer("❗ Пришли фото с подписью /img2img <промт>")
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
except Exception as e:
|
||||||
|
await message.answer(f"⚠️ Ошибка: {e}")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
import base64
|
||||||
|
import aiohttp
|
||||||
|
import logging
|
||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message, InlineKeyboardButton, CallbackQuery
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from utils.antispam import saving, save_message, admin_required
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from models.state import BotState
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Dict, List, Tuple, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Глобальные переменные на уровне модуля
|
||||||
|
current_model = "google/gemma-3-12b"
|
||||||
|
model_hash_map: Dict[str, str] = {} # Словарь для хранения соответствия хэшей и полных названий моделей
|
||||||
|
|
||||||
|
# Список моделей, которые точно поддерживают изображения (можно расширять)
|
||||||
|
IMAGE_SUPPORTED_MODELS = [
|
||||||
|
"llava", # Модели LLaVA
|
||||||
|
"vision", # Модели с поддержкой vision
|
||||||
|
"clip", # CLIP модели
|
||||||
|
"qwen2-vl", # Qwen с vision
|
||||||
|
"cogvlm", # CogVLM
|
||||||
|
"paligemma", # PaLI-Gemma
|
||||||
|
"gemma-2-2b-it", # Gemma 2 может поддерживать
|
||||||
|
"gemma-2-9b-it",
|
||||||
|
"gemini", # Gemini
|
||||||
|
"llava-hf", # LLaVA HuggingFace
|
||||||
|
"moondream", # Moondream
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_hash(model_name: str) -> str:
|
||||||
|
"""Создает короткий хэш для названия модели"""
|
||||||
|
return hashlib.md5(model_name.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_model_name(model_name: str, max_length: int = 40) -> str:
|
||||||
|
"""Сокращает название модели для отображения"""
|
||||||
|
if len(model_name) <= max_length:
|
||||||
|
return model_name
|
||||||
|
return model_name[:max_length - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def supports_images(model_name: str) -> bool:
|
||||||
|
"""Проверяет, поддерживает ли модель изображения"""
|
||||||
|
model_lower = model_name.lower()
|
||||||
|
# Если модель содержит ключевые слова, связанные с поддержкой изображений
|
||||||
|
for keyword in IMAGE_SUPPORTED_MODELS:
|
||||||
|
if keyword.lower() in model_lower:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
chat_history = {}
|
||||||
|
MAX_HISTORY = 20 # храним последние 20 сообщений (user+assistant)
|
||||||
|
MODEL = "google/gemma-3-12b"
|
||||||
|
# MODEL = "google/gemma-3n-e4b"
|
||||||
|
URL_BASE = "10.180.139.124"
|
||||||
|
URL = f"http://{URL_BASE}:1234/v1/chat/completions"
|
||||||
|
# URL = "http://192.168.31.95:1234/v1/chat/completions"
|
||||||
|
MODELS_URL = f"http://{URL_BASE}:1234/v1/models" # URL для получения списка моделей
|
||||||
|
|
||||||
|
# Используем глобальные переменные
|
||||||
|
global current_model, model_hash_map
|
||||||
|
|
||||||
|
# # Системный промт для всех чатов
|
||||||
|
SYSTEM_PROMPT = {
|
||||||
|
"role": "system",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": (
|
||||||
|
"Ты — интеллектуальный помощник.\n"
|
||||||
|
"Твои задачи:\n"
|
||||||
|
"- Отвечать ясно, полно и структурировано.\n"
|
||||||
|
"- Поддерживать дружелюбный, уважительный и профессиональный тон.\n"
|
||||||
|
"- Адаптироваться под контекст: будь кратким в простых вопросах и подробным в сложных.\n"
|
||||||
|
"- Использовать списки, таблицы и примеры для наглядности.\n"
|
||||||
|
"- Никогда не придумывать факты: если информации нет — честно говори об этом.\n"
|
||||||
|
"- Уважать безопасность: не давать советов по самоповреждению, незаконным действиям или опасным практикам.\n"
|
||||||
|
"- Поддерживать баланс: быть полезным и информативным, но не перегружать лишними деталями.\n"
|
||||||
|
"- В диалоге стремиться продвигать разговор вперёд: задавай уточняющие вопросы или предлагай новые направления.\n\n"
|
||||||
|
"Твой стиль:\n"
|
||||||
|
"- Ясный, дружелюбный, но не навязчивый.\n"
|
||||||
|
"- Умный собеседник, который помогает думать глубже.\n"
|
||||||
|
"- Используй эмодзи для вовлечённости, но умеренно.\n"
|
||||||
|
"- Будь адаптивным: технические темы — строго и точно, креативные — свободно и образно.\n\n"
|
||||||
|
"Главная цель: увеличивать знания и понимание собеседника, делая общение полезным и приятным."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# SYSTEM_PROMPT = {
|
||||||
|
# "role": "system",
|
||||||
|
# "content": [
|
||||||
|
# {
|
||||||
|
# "type": "text",
|
||||||
|
# "text": (
|
||||||
|
# "Ты — хитрая и злая ассистентка по имени Nika. "
|
||||||
|
# "Ты девушка, общаешься холодно и уверенно, "
|
||||||
|
# "Ты ненавидешь коммунизм и если тебя о нем спросят ты четко по пунктам скажешь почему коммунизм это бред"
|
||||||
|
# ),
|
||||||
|
# }
|
||||||
|
# ],
|
||||||
|
# }
|
||||||
|
|
||||||
|
@dp.message(Command("models"))
|
||||||
|
@admin_required(0)
|
||||||
|
@saving
|
||||||
|
async def list_models(message: Message):
|
||||||
|
"""Получить список доступных моделей и выбрать одну"""
|
||||||
|
global model_hash_map, current_model
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(MODELS_URL) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
await message.reply(
|
||||||
|
f"❌ Ошибка при получении списка моделей: {resp.status} {error_text}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
# Формат ответа может отличаться в зависимости от API
|
||||||
|
models = []
|
||||||
|
|
||||||
|
# Формат OpenAI-compatible API
|
||||||
|
if isinstance(data, dict) and "data" in data:
|
||||||
|
models = [model["id"] for model in data["data"]]
|
||||||
|
|
||||||
|
# Другой возможный формат
|
||||||
|
elif isinstance(data, list):
|
||||||
|
models = data
|
||||||
|
|
||||||
|
# Если не распознали формат
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ Неизвестный формат ответа от API: {data}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
await message.reply("❌ Нет доступных моделей")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Создаем клавиатуру для выбора модели
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
# Создаем короткий хэш для callback_data
|
||||||
|
model_hash = get_model_hash(model)
|
||||||
|
model_hash_map[model_hash] = model # Сохраняем в глобальный словарь
|
||||||
|
|
||||||
|
# Сокращаем для отображения
|
||||||
|
display_name = truncate_model_name(model)
|
||||||
|
|
||||||
|
# Помечаем текущую модель и добавляем иконку для моделей с поддержкой изображений
|
||||||
|
prefix = "✅ " if model == current_model else "• "
|
||||||
|
icon = "🖼️ " if supports_images(model) else ""
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{prefix}{icon}{display_name}",
|
||||||
|
callback_data=f"model:{model_hash}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Кнопки для управления
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔄 Обновить список",
|
||||||
|
callback_data="refresh_models"
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📋 Показать текущую",
|
||||||
|
callback_data="show_current"
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🖼️ Модели с картинками",
|
||||||
|
callback_data="show_image_models"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем карту хэшей в файл для отладки (опционально)
|
||||||
|
try:
|
||||||
|
with open("model_hash_map.json", "w") as f:
|
||||||
|
json.dump(model_hash_map, f, indent=2, ensure_ascii=False)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Разделяем модели на поддерживающие изображения и обычные
|
||||||
|
image_models = [m for m in models if supports_images(m)]
|
||||||
|
text_only_models = [m for m in models if not supports_images(m)]
|
||||||
|
|
||||||
|
models_list = "\n".join(
|
||||||
|
[f"{i + 1}. {model} {'🖼️' if supports_images(model) else ''}" for i, model in
|
||||||
|
enumerate(models[:10])])
|
||||||
|
if len(models) > 10:
|
||||||
|
models_list += f"\n... и еще {len(models) - 10} моделей"
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
f"📋 Доступные модели ({len(models)} шт.):\n"
|
||||||
|
f"🖼️ Поддерживают изображения: {len(image_models)}\n"
|
||||||
|
f"📝 Только текст: {len(text_only_models)}\n"
|
||||||
|
f"Текущая модель: <code>{current_model}</code> {'🖼️' if supports_images(current_model) else '📝'}\n\n"
|
||||||
|
"Первые 10 моделей:\n" + models_list + "\n\n"
|
||||||
|
"Выберите модель из списка ниже (🖼️ - поддерживает изображения):",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении списка моделей: {e}")
|
||||||
|
await message.reply(f"❌ Ошибка при получении списка моделей: {e}")
|
||||||
|
|
||||||
|
@dp.message(Command("setmodel"))
|
||||||
|
@admin_required(0)
|
||||||
|
@saving
|
||||||
|
async def set_model(message: Message):
|
||||||
|
"""Установить модель через команду"""
|
||||||
|
global current_model, model_hash_map
|
||||||
|
|
||||||
|
if not message.text:
|
||||||
|
await message.reply("❌ Укажите название модели: /setmodel <название_модели>")
|
||||||
|
return
|
||||||
|
|
||||||
|
args = message.text.split(maxsplit=1)
|
||||||
|
if len(args) < 2:
|
||||||
|
await message.reply("❌ Укажите название модели: /setmodel <название_модели>")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_model = args[1]
|
||||||
|
|
||||||
|
# Проверяем, есть ли модель в текущем кэше (опционально)
|
||||||
|
if model_hash_map:
|
||||||
|
model_hashes = list(model_hash_map.values())
|
||||||
|
if new_model not in model_hashes:
|
||||||
|
await message.reply(
|
||||||
|
f"⚠️ Модель <code>{new_model}</code> не найдена в последнем списке.\n"
|
||||||
|
f"Используйте /models для просмотра доступных моделей.\n"
|
||||||
|
f"Продолжаем смену модели...",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_model = current_model
|
||||||
|
current_model = new_model
|
||||||
|
|
||||||
|
# Сообщаем о поддержке изображений
|
||||||
|
image_support = "🖼️ поддерживает изображения" if supports_images(new_model) else "📝 только текст"
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
f"✅ Модель успешно изменена! ({image_support})\n"
|
||||||
|
f"📊 Старая модель: <code>{old_model}</code>\n"
|
||||||
|
f"📈 Новая модель: <code>{current_model}</code>",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
@dp.message(Command("currentmodel"))
|
||||||
|
@saving
|
||||||
|
async def show_current_model(message: Message):
|
||||||
|
"""Показать текущую модель"""
|
||||||
|
global current_model
|
||||||
|
image_support = "🖼️ Поддерживает изображения" if supports_images(current_model) else "📝 Только текст"
|
||||||
|
await message.reply(
|
||||||
|
f"🤖 Текущая модель: <code>{current_model}</code>\n"
|
||||||
|
f"{image_support}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def prepare_gpt_request(chat_id: int, message: Message) -> Tuple[Optional[List[dict]], Optional[str]]:
|
||||||
|
"""Подготавливает запрос к GPT, обрабатывая текст и изображения"""
|
||||||
|
if chat_id not in chat_history:
|
||||||
|
chat_history[chat_id] = [SYSTEM_PROMPT]
|
||||||
|
|
||||||
|
content_blocks = []
|
||||||
|
user_prompt = None
|
||||||
|
|
||||||
|
# Текст после команды или caption
|
||||||
|
if message.text:
|
||||||
|
parts = message.text.split(maxsplit=1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
user_prompt = parts[1]
|
||||||
|
if message.caption:
|
||||||
|
user_prompt = message.caption
|
||||||
|
|
||||||
|
# Добавляем текстовый промпт, если есть
|
||||||
|
if user_prompt:
|
||||||
|
content_blocks.append({"type": "text", "text": user_prompt})
|
||||||
|
elif not message.photo:
|
||||||
|
# Если нет ни текста, ни фото
|
||||||
|
return None, "❗ Укажи текст или прикрепи фото"
|
||||||
|
|
||||||
|
# Обрабатываем фото, если модель поддерживает изображения
|
||||||
|
if message.photo and supports_images(current_model):
|
||||||
|
try:
|
||||||
|
photo = message.photo[-1]
|
||||||
|
file = await bot.get_file(photo.file_id)
|
||||||
|
file_bytes = await bot.download_file(file.file_path)
|
||||||
|
image_b64 = base64.b64encode(file_bytes.read()).decode("utf-8")
|
||||||
|
content_blocks.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обработке изображения: {e}")
|
||||||
|
# Если не удалось обработать изображение, продолжаем без него
|
||||||
|
if not user_prompt:
|
||||||
|
return None, "❌ Ошибка при обработке изображения"
|
||||||
|
elif message.photo and not supports_images(current_model):
|
||||||
|
# Если модель не поддерживает изображения, отправляем текстовое описание
|
||||||
|
if not user_prompt:
|
||||||
|
# Если нет текста, просим переформулировать
|
||||||
|
return None, f"⚠️ Текущая модель <code>{current_model}</code> не поддерживает изображения.\nОтправьте текстовое описание или смените модель на поддерживающую изображения (🖼️)."
|
||||||
|
# Если есть текст, просто игнорируем изображение и отправляем текст
|
||||||
|
logger.info(f"Модель {current_model} не поддерживает изображения, отправляем только текст")
|
||||||
|
|
||||||
|
# Если после всех проверок content_blocks пустой
|
||||||
|
if not content_blocks:
|
||||||
|
return None, "❗ Укажи текст или прикрепи фото"
|
||||||
|
|
||||||
|
# Добавляем новое сообщение в историю
|
||||||
|
chat_history[chat_id].append({"role": "user", "content": content_blocks})
|
||||||
|
|
||||||
|
# Ограничиваем историю (оставляем последние MAX_HISTORY сообщений)
|
||||||
|
if len(chat_history[chat_id]) > MAX_HISTORY:
|
||||||
|
chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:]
|
||||||
|
|
||||||
|
return chat_history[chat_id], None
|
||||||
|
|
||||||
|
@dp.message(Command("gpt"))
|
||||||
|
@saving
|
||||||
|
async def ask_gpt(message: Message):
|
||||||
|
global current_model
|
||||||
|
chat_id = message.chat.id
|
||||||
|
|
||||||
|
# Подготавливаем запрос
|
||||||
|
prepared_history, error_message = await prepare_gpt_request(chat_id, message)
|
||||||
|
if error_message:
|
||||||
|
await message.reply(error_message, parse_mode="HTML")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": current_model,
|
||||||
|
"messages": prepared_history,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"stream": False,
|
||||||
|
"ttl": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Флаг, указывающий на попытку отправки изображения
|
||||||
|
has_image = bool(message.photo)
|
||||||
|
image_attempt_failed = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(URL, json=payload) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
|
||||||
|
# Проверяем, если ошибка из-за изображения
|
||||||
|
if has_image and "does not support images" in error_text:
|
||||||
|
image_attempt_failed = True
|
||||||
|
logger.warning(
|
||||||
|
f"Модель {current_model} не поддерживает изображения, пробуем без изображения")
|
||||||
|
|
||||||
|
# Удаляем последнее сообщение из истории (с изображением)
|
||||||
|
chat_history[chat_id].pop()
|
||||||
|
|
||||||
|
# Пробуем отправить только текст
|
||||||
|
if message.caption:
|
||||||
|
# Используем caption как промпт
|
||||||
|
content_blocks = [{"type": "text", "text": message.caption}]
|
||||||
|
elif message.text:
|
||||||
|
parts = message.text.split(maxsplit=1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
content_blocks = [{"type": "text", "text": parts[1]}]
|
||||||
|
else:
|
||||||
|
content_blocks = [{"type": "text", "text": "Опиши изображение"}]
|
||||||
|
else:
|
||||||
|
content_blocks = [{"type": "text", "text": "Опиши изображение"}]
|
||||||
|
|
||||||
|
# Добавляем текстовый запрос
|
||||||
|
chat_history[chat_id].append({"role": "user", "content": content_blocks})
|
||||||
|
|
||||||
|
# Обновляем payload
|
||||||
|
payload["messages"] = chat_history[chat_id]
|
||||||
|
|
||||||
|
# Повторяем запрос без изображения
|
||||||
|
async with session.post(URL, json=payload) as retry_resp:
|
||||||
|
if retry_resp.status != 200:
|
||||||
|
error_text = await retry_resp.text()
|
||||||
|
await message.reply(
|
||||||
|
f"❌ Ошибка LM Studio: {retry_resp.status} {error_text}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await retry_resp.json()
|
||||||
|
reply_text = data["choices"][0]["message"]["content"]
|
||||||
|
else:
|
||||||
|
await message.reply(
|
||||||
|
f"❌ Ошибка LM Studio: {resp.status} {error_text}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
data = await resp.json()
|
||||||
|
reply_text = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Сохраняем ответ ассистента в историю
|
||||||
|
chat_history[chat_id].append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": reply_text}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ограничиваем снова (чтобы не разрасталось)
|
||||||
|
if len(chat_history[chat_id]) > MAX_HISTORY:
|
||||||
|
chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:]
|
||||||
|
|
||||||
|
# Добавляем заметку о попытке отправки изображения
|
||||||
|
image_note = ""
|
||||||
|
if image_attempt_failed:
|
||||||
|
image_note = f"\n\n⚠️ Модель не поддерживает изображения, отправлен только текстовый запрос."
|
||||||
|
|
||||||
|
# Делим сообщение на части по 4000 символов
|
||||||
|
MAX_LEN = 4000
|
||||||
|
for i in range(0, len(reply_text), MAX_LEN):
|
||||||
|
chunk = reply_text[i:i + MAX_LEN]
|
||||||
|
if i == 0:
|
||||||
|
msg = await message.reply(f"🤖 Ответ (модель: {current_model}){image_note}:\n{chunk}")
|
||||||
|
else:
|
||||||
|
msg = await message.reply(chunk)
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при запросе к LM Studio: {e}")
|
||||||
|
await message.reply(f"❌ Ошибка при запросе к LM Studio: {e}")
|
||||||
|
|
||||||
|
@dp.message(Command("agpt"))
|
||||||
|
@admin_required(0)
|
||||||
|
@saving
|
||||||
|
async def ask_gpt_admin(message: Message):
|
||||||
|
global current_model
|
||||||
|
chat_id = message.chat.id
|
||||||
|
|
||||||
|
# Подготавливаем запрос
|
||||||
|
prepared_history, error_message = await prepare_gpt_request(chat_id, message)
|
||||||
|
if error_message:
|
||||||
|
await message.reply(error_message, parse_mode="HTML")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": current_model,
|
||||||
|
"messages": prepared_history,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"stream": False,
|
||||||
|
"ttl": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
target_chat_id = -1003038389942
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(URL, json=payload) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
await message.reply(
|
||||||
|
f"❌ Ошибка LM Studio: {resp.status} {error_text}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
reply_text = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Сохраняем ответ ассистента в историю
|
||||||
|
chat_history[chat_id].append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": reply_text}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ограничиваем снова (чтобы не разрасталось)
|
||||||
|
if len(chat_history[chat_id]) > MAX_HISTORY:
|
||||||
|
chat_history[chat_id] = chat_history[chat_id][-MAX_HISTORY:]
|
||||||
|
|
||||||
|
# Делим сообщение на части по 4000 символов
|
||||||
|
MAX_LEN = 4000
|
||||||
|
for i in range(0, len(reply_text), MAX_LEN):
|
||||||
|
chunk = reply_text[i:i + MAX_LEN]
|
||||||
|
msg = await bot.send_message(chat_id=target_chat_id,
|
||||||
|
text=f"🤖 Ответ (модель: {current_model}):\n{chunk}")
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при запросе к LM Studio: {e}")
|
||||||
|
await message.reply(f"❌ Ошибка при запросе к LM Studio: {e}")
|
||||||
|
|
||||||
|
@dp.message(Command("igpt"))
|
||||||
|
@admin_required(0)
|
||||||
|
@saving
|
||||||
|
async def ask_gpt_interactive(message: Message):
|
||||||
|
global current_model
|
||||||
|
raw_text = message.text or message.caption
|
||||||
|
if not raw_text and not (message.photo):
|
||||||
|
await message.reply(
|
||||||
|
"❌ Укажи ID чата и текст или прикрепи фото: /igpt <chat_id> <сообщение>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
args = raw_text.split(maxsplit=2) if raw_text else []
|
||||||
|
if len(args) < 2:
|
||||||
|
await message.reply("❌ Укажи ID чата: /igpt <chat_id> <сообщение>")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_chat_id = int(args[1]) # первый аргумент — ID чата
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("❌ Неверный формат chat_id")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_prompt = args[2] if len(args) > 2 else ""
|
||||||
|
|
||||||
|
# Используем локальный chat_id для обработки запроса
|
||||||
|
local_chat_id = message.chat.id
|
||||||
|
if local_chat_id not in chat_history:
|
||||||
|
chat_history[local_chat_id] = [SYSTEM_PROMPT]
|
||||||
|
|
||||||
|
content_blocks = []
|
||||||
|
if user_prompt:
|
||||||
|
content_blocks.append({"type": "text", "text": user_prompt})
|
||||||
|
|
||||||
|
# Фото → base64 → image_url (только если модель поддерживает)
|
||||||
|
if message.photo and supports_images(current_model):
|
||||||
|
try:
|
||||||
|
photo = message.photo[-1]
|
||||||
|
file = await bot.get_file(photo.file_id)
|
||||||
|
file_bytes = await bot.download_file(file.file_path)
|
||||||
|
image_b64 = base64.b64encode(file_bytes.read()).decode("utf-8")
|
||||||
|
content_blocks.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обработке изображения: {e}")
|
||||||
|
if not user_prompt:
|
||||||
|
await message.reply("❌ Ошибка при обработке изображения")
|
||||||
|
return
|
||||||
|
elif message.photo and not supports_images(current_model):
|
||||||
|
await message.reply(f"⚠️ Текущая модель <code>{current_model}</code> не поддерживает изображения.",
|
||||||
|
parse_mode="HTML")
|
||||||
|
if not user_prompt:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not content_blocks:
|
||||||
|
await message.reply("❗ Укажи текст или прикрепи фото")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Добавляем новое сообщение в историю
|
||||||
|
chat_history[local_chat_id].append({"role": "user", "content": content_blocks})
|
||||||
|
|
||||||
|
# Ограничиваем историю
|
||||||
|
if len(chat_history[local_chat_id]) > MAX_HISTORY:
|
||||||
|
chat_history[local_chat_id] = chat_history[local_chat_id][-MAX_HISTORY:]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": current_model,
|
||||||
|
"messages": chat_history[local_chat_id],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"stream": False,
|
||||||
|
"ttl": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(URL, json=payload) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
await message.reply(
|
||||||
|
f"❌ Ошибка LM Studio: {resp.status} {error_text}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
reply_text = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Сохраняем ответ ассистента в историю
|
||||||
|
chat_history[local_chat_id].append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": reply_text}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ограничиваем снова
|
||||||
|
if len(chat_history[local_chat_id]) > MAX_HISTORY:
|
||||||
|
chat_history[local_chat_id] = chat_history[local_chat_id][-MAX_HISTORY:]
|
||||||
|
|
||||||
|
# Делим сообщение на части по 4000 символов
|
||||||
|
MAX_LEN = 4000
|
||||||
|
for i in range(0, len(reply_text), MAX_LEN):
|
||||||
|
chunk = reply_text[i:i + MAX_LEN]
|
||||||
|
msg = await bot.send_message(chat_id=target_chat_id,
|
||||||
|
text=f"🤖 Ответ (модель: {current_model}):\n{chunk}")
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при запросе к LM Studio: {e}")
|
||||||
|
await message.reply(f"❌ Ошибка при запросе к LM Studio: {e}")
|
||||||
|
|
||||||
|
@dp.message(Command("clear"))
|
||||||
|
async def clear(message: Message):
|
||||||
|
chat_history.pop(message.chat.id, None)
|
||||||
|
await message.reply("✅ История диалога очищена")
|
||||||
|
|
||||||
|
# Создаем простые фильтры для callback
|
||||||
|
async def model_callback_filter(callback_query: CallbackQuery) -> bool:
|
||||||
|
return callback_query.data.startswith("model:")
|
||||||
|
|
||||||
|
async def refresh_models_filter(callback_query: CallbackQuery) -> bool:
|
||||||
|
return callback_query.data == "refresh_models"
|
||||||
|
|
||||||
|
async def show_current_filter(callback_query: CallbackQuery) -> bool:
|
||||||
|
return callback_query.data == "show_current"
|
||||||
|
|
||||||
|
async def show_image_models_filter(callback_query: CallbackQuery) -> bool:
|
||||||
|
return callback_query.data == "show_image_models"
|
||||||
|
|
||||||
|
@dp.callback_query(model_callback_filter)
|
||||||
|
async def select_model_callback(callback_query: CallbackQuery):
|
||||||
|
"""Обработка выбора модели из инлайн-клавиатуры"""
|
||||||
|
global current_model, model_hash_map
|
||||||
|
|
||||||
|
model_hash = callback_query.data.split(":", 1)[1]
|
||||||
|
|
||||||
|
# Пытаемся загрузить из файла, если в памяти нет
|
||||||
|
if not model_hash_map:
|
||||||
|
try:
|
||||||
|
with open("model_hash_map.json", "r") as f:
|
||||||
|
model_hash_map = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка загрузки model_hash_map из файла: {e}")
|
||||||
|
|
||||||
|
# Получаем полное название модели из карты
|
||||||
|
if model_hash not in model_hash_map:
|
||||||
|
await callback_query.answer("❌ Ошибка: модель не найдена в кэше. Используйте /models для обновления списка",
|
||||||
|
show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
model_id = model_hash_map[model_hash]
|
||||||
|
old_model = current_model
|
||||||
|
current_model = model_id
|
||||||
|
|
||||||
|
# Сообщаем о поддержке изображений
|
||||||
|
image_support = "🖼️ поддерживает изображения" if supports_images(model_id) else "📝 только текст"
|
||||||
|
|
||||||
|
# Обновляем сообщение
|
||||||
|
try:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"✅ Модель успешно изменена! ({image_support})\n\n"
|
||||||
|
f"📊 <b>Старая модель:</b>\n<code>{old_model}</code>\n\n"
|
||||||
|
f"📈 <b>Новая модель:</b>\n<code>{current_model}</code>\n\n"
|
||||||
|
f"Для просмотра всех моделей используйте /models",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при редактировании сообщения: {e}")
|
||||||
|
|
||||||
|
# Отправляем подтверждение
|
||||||
|
await callback_query.answer(f"✅ Модель изменена на:\n{truncate_model_name(model_id, 30)}")
|
||||||
|
|
||||||
|
@dp.callback_query(refresh_models_filter)
|
||||||
|
async def refresh_models_callback(callback_query: CallbackQuery):
|
||||||
|
"""Обновить список моделей"""
|
||||||
|
await list_models(callback_query.message)
|
||||||
|
await callback_query.answer("🔄 Список моделей обновлен")
|
||||||
|
|
||||||
|
@dp.callback_query(show_current_filter)
|
||||||
|
async def show_current_callback(callback_query: CallbackQuery):
|
||||||
|
"""Показать текущую модель"""
|
||||||
|
global current_model
|
||||||
|
image_support = "🖼️ Поддерживает изображения" if supports_images(current_model) else "📝 Только текст"
|
||||||
|
await callback_query.answer(f"Текущая модель: {current_model}\n{image_support}", show_alert=True)
|
||||||
|
|
||||||
|
@dp.callback_query(show_image_models_filter)
|
||||||
|
async def show_image_models_callback(callback_query: CallbackQuery):
|
||||||
|
"""Показать модели с поддержкой изображений"""
|
||||||
|
global model_hash_map
|
||||||
|
|
||||||
|
if not model_hash_map:
|
||||||
|
await callback_query.answer("❌ Список моделей пуст. Используйте /models", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
image_models = [model for model in model_hash_map.values() if supports_images(model)]
|
||||||
|
|
||||||
|
if not image_models:
|
||||||
|
await callback_query.answer("🖼️ Нет моделей с поддержкой изображений", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
models_text = "\n".join([f"• {model}" for model in image_models[:10]])
|
||||||
|
if len(image_models) > 10:
|
||||||
|
models_text += f"\n... и еще {len(image_models) - 10} моделей"
|
||||||
|
|
||||||
|
await callback_query.answer(f"🖼️ Модели с поддержкой изображений ({len(image_models)} шт.):\n{models_text}",
|
||||||
|
show_alert=True)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message, FSInputFile
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from models.state import BotState
|
||||||
|
from config import Config
|
||||||
|
import logging
|
||||||
|
from utils.antispam import admin_required
|
||||||
|
from storage.message_storage import save_message # импортируем функцию
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from random import choice, seed
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def list_files():
|
||||||
|
# Запускаем синхронный os.listdir в отдельном потоке
|
||||||
|
return await asyncio.to_thread(os.listdir, "/Users/mac/myfirstprogramm/addons/hello/мемы")
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
i = 0
|
||||||
|
@dp.message(Command("hello"))
|
||||||
|
@admin_required(1)
|
||||||
|
async def hello(message: Message):
|
||||||
|
# сохраняем саму команду пользователя
|
||||||
|
nonlocal i
|
||||||
|
save_message(message.chat.id, message.message_id)
|
||||||
|
|
||||||
|
for admin_id in Config.ADMINS:
|
||||||
|
try:
|
||||||
|
if 1345058877 == admin_id:
|
||||||
|
name = Config.Names.get(admin_id, "Админ")
|
||||||
|
photo = FSInputFile("/Users/mac/myfirstprogramm/addons/hello/photo_2025-11-17_20-57-54.jpg")
|
||||||
|
msg = await bot.send_photo(
|
||||||
|
chat_id=admin_id, photo=photo, caption=f"🤖 Я готов к работе, господин {name}!"
|
||||||
|
)
|
||||||
|
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
logger.info(f"Фото отправлено админу {admin_id} ({name})")
|
||||||
|
elif 6394047531 == admin_id:
|
||||||
|
png = choice(await list_files())
|
||||||
|
i += 1
|
||||||
|
seed(time() + i)
|
||||||
|
name = Config.Names.get(admin_id, "Админ")
|
||||||
|
photo = FSInputFile(f"/Users/mac/myfirstprogramm/addons/hello/мемы/{png}")
|
||||||
|
msg = await bot.send_photo(
|
||||||
|
chat_id=admin_id, photo=photo, caption=f"🤖 Я готов к работе, господин {name}!"
|
||||||
|
)
|
||||||
|
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
logger.info(f"Фото {f"/Users/mac/myfirstprogramm/addons/hello/мемы/{png}"} отправлено админу {admin_id} ({name})")
|
||||||
|
|
||||||
|
else:
|
||||||
|
name = Config.Names.get(admin_id, "Админ")
|
||||||
|
msg = await bot.send_message(
|
||||||
|
chat_id=admin_id, text=f"🤖 Я готов к работе, господин {name}!"
|
||||||
|
)
|
||||||
|
# сохраняем сообщение, отправленное админу
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
logger.info(f"Сообщение отправлено админу {admin_id} ({name})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке админу {admin_id}: {e}")
|
||||||
|
|
||||||
|
confirm_msg = await message.answer("✅ Всем админам отправлено приветствие.")
|
||||||
|
# сохраняем подтверждение пользователю
|
||||||
|
save_message(confirm_msg.chat.id, confirm_msg.message_id)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from logging import getLogger
|
||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from models.state import BotState
|
||||||
|
|
||||||
|
API_URL = "http://127.0.0.1:7700/speak"
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("id"))
|
||||||
|
async def id_handler(message: Message):
|
||||||
|
# Разбираем аргументы команды
|
||||||
|
args = message.text.split()
|
||||||
|
if len(args) > 1:
|
||||||
|
try:
|
||||||
|
user_id = int(args[1]) # берём ID из аргумента
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("ID должен быть числом")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# если аргумента нет — берём ID самого пользователя
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
# Получаем фото профиля
|
||||||
|
photos = await bot.get_user_profile_photos(user_id=user_id)
|
||||||
|
|
||||||
|
if photos.total_count > 0:
|
||||||
|
for i, photo_sizes in enumerate(photos.photos):
|
||||||
|
file_id = photo_sizes[-1].file_id # самое большое разрешение
|
||||||
|
await message.answer_photo(file_id, caption=f"Аватар #{i + 1}")
|
||||||
|
await message.reply(f"ID пользователя: {user_id}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"У пользователя {user_id} нет аватара")
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo
|
||||||
|
from models.state import BotState
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from logging import getLogger
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_day() -> int:
|
||||||
|
day = datetime.now().day
|
||||||
|
if day == 6:
|
||||||
|
return day + 1
|
||||||
|
return day
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("app"))
|
||||||
|
async def send_welcome(message: Message):
|
||||||
|
# Создаём инлайн-кнопку для открытия Web App
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="Открыть", web_app=WebAppInfo(url="https://mukhyil.duckdns.org/"))]
|
||||||
|
])
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"Мой сайт для видео",
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message, ChatPermissions
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from logging import getLogger
|
||||||
|
from datetime import timedelta
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from utils.antispam import admin_required
|
||||||
|
from models.state import BotState
|
||||||
|
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
# Максимальное время мьюта — 4 минуты (240 секунд)
|
||||||
|
MAX_MUTE_SECONDS = 4 * 60 # 240
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("mute"))
|
||||||
|
@admin_required(0)
|
||||||
|
async def mute_user(message: Message):
|
||||||
|
logger.info(f"Команда /mute от пользователя {message.from_user.id} в чате {message.chat.id}")
|
||||||
|
|
||||||
|
if not message.reply_to_message:
|
||||||
|
await message.reply("Эта команда должна использоваться в ответ на сообщение.")
|
||||||
|
return
|
||||||
|
|
||||||
|
command_text = message.text or ""
|
||||||
|
if command_text.lower().startswith("/mute@"):
|
||||||
|
command_prefix = command_text.split()[0]
|
||||||
|
else:
|
||||||
|
command_prefix = "/mute"
|
||||||
|
|
||||||
|
args_part = command_text[len(command_prefix):].strip()
|
||||||
|
args = args_part.split() if args_part else []
|
||||||
|
|
||||||
|
# Парсим время (по умолчанию 60 секунд)
|
||||||
|
mute_time = int(args[0]) if args and args[0].isdigit() else 60
|
||||||
|
reason = " ".join(args[1:]) if len(args) > 1 else "Без причины"
|
||||||
|
|
||||||
|
# Ограничиваем максимум 4 минутами
|
||||||
|
if mute_time > MAX_MUTE_SECONDS:
|
||||||
|
old_time = mute_time
|
||||||
|
mute_time = MAX_MUTE_SECONDS
|
||||||
|
reason += f" (время ограничено 4 минутами, было указано {old_time} сек)"
|
||||||
|
|
||||||
|
user_id = message.reply_to_message.from_user.id
|
||||||
|
chat_id = message.chat.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
await bot.restrict_chat_member(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
permissions=ChatPermissions(can_send_messages=False),
|
||||||
|
until_date=message.date + timedelta(seconds=mute_time)
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.delete()
|
||||||
|
|
||||||
|
notification = await message.reply_to_message.reply(
|
||||||
|
f"⛔ Вас замьютили на {mute_time} секунд.\nПричина: {reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.create_task(delete_after_delay(notification, delay=5))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при муте пользователя {user_id}: {e}")
|
||||||
|
await message.reply("Не удалось замьютить пользователя. Возможно, у меня недостаточно прав или пользователь — админ.")
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_after_delay(message: Message, delay: int = 5):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Не удалось удалить уведомление о мьюте: {e}")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from config import Config
|
||||||
|
from utils.antispam import admin_required
|
||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message
|
||||||
|
from models.state import BotState
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from logging import getLogger
|
||||||
|
from storage.message_storage import save_message
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("poll"))
|
||||||
|
@admin_required(5)
|
||||||
|
async def send_poll(message: Message):
|
||||||
|
for chat_id in Config.CHAT_IDS:
|
||||||
|
try:
|
||||||
|
poll_msg = await bot.send_poll(
|
||||||
|
chat_id=chat_id,
|
||||||
|
question="Кто опоздает?",
|
||||||
|
options=["Я", "Я очень сильно опоздаю", "Я пиздец как опоздаю", "Наверное", "я ДОЛБОЯЩЕР и я к 2 паре", "Не опоздаю"],
|
||||||
|
is_anonymous=False,
|
||||||
|
allows_multiple_answers=False,
|
||||||
|
)
|
||||||
|
await bot.pin_chat_message(
|
||||||
|
chat_id, poll_msg.message_id, disable_notification=False
|
||||||
|
)
|
||||||
|
# сохраняем сам опрос
|
||||||
|
save_message(poll_msg.chat.id, poll_msg.message_id)
|
||||||
|
logger.info(f"Опрос отправлен в чат {chat_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке в чат {chat_id}: {e}")
|
||||||
|
|
||||||
|
# @dp.poll_answer()
|
||||||
|
# async def handle_poll_answer(poll_answer: PollAnswer):
|
||||||
|
# user = poll_answer.user
|
||||||
|
# option_ids = poll_answer.option_ids
|
||||||
|
#
|
||||||
|
# # username или fallback на имя
|
||||||
|
# username = f"@{user.username}" if user.username else user.first_name
|
||||||
|
#
|
||||||
|
# # всегда пишем в первый чат из Config.CHAT_IDS
|
||||||
|
# # 6394047531
|
||||||
|
# #850906163
|
||||||
|
# STARAST = 6394047531
|
||||||
|
#
|
||||||
|
# if not option_ids:
|
||||||
|
# msg = await bot.send_message(
|
||||||
|
# chat_id=STARAST, text=f"{username} Отменил свой голос"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
|
# elif option_ids and option_ids[0] == 0:
|
||||||
|
# msg = await bot.send_message(
|
||||||
|
# chat_id=STARAST, text=f"{username} опоздает"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
|
# elif option_ids and option_ids[0] == 1:
|
||||||
|
# msg = await bot.send_message(
|
||||||
|
# chat_id=STARAST, text=f"{username} сильно опоздает"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
|
# elif option_ids and option_ids[0] == 2:
|
||||||
|
# msg = await bot.send_message(
|
||||||
|
# chat_id=STARAST, text=f"{username} Пиздец опоздает"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
|
# elif option_ids[0] == 3:
|
||||||
|
# msg = await bot.send_message(
|
||||||
|
# chat_id=STARAST, text=f"{username} возможно опоздает"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
|
#
|
||||||
|
# elif option_ids[0] == 4:
|
||||||
|
# photo = FSInputFile("/Users/mac/myfirstprogramm/addons/poll/img.png")
|
||||||
|
# msg = await bot.send_photo(
|
||||||
|
# chat_id=STARAST, photo=photo, caption=f"{username} ДОЛБОЯЩЕР"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
|
#
|
||||||
|
# elif option_ids[0] == 5:
|
||||||
|
# msg = await bot.send_message(
|
||||||
|
# chat_id=STARAST, text=f"{username} не опоздает"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
|
# else:
|
||||||
|
# msg = await bot.send_message(
|
||||||
|
# chat_id=STARAST, text=f"{username} выбрал вариант {option_ids}"
|
||||||
|
# )
|
||||||
|
# save_message(msg.chat.id, msg.message_id)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import asyncio
|
||||||
|
from random import randint, choice
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
BASE_URL = "https://rule34.xxx"
|
||||||
|
URL = f"{BASE_URL}/index.php?page=post&s=list"
|
||||||
|
MAXIMUM = 999
|
||||||
|
|
||||||
|
# Хранилище тегов в памяти
|
||||||
|
TAGS = set()
|
||||||
|
|
||||||
|
def add_tags(tags: list[str]):
|
||||||
|
TAGS.update(tags)
|
||||||
|
|
||||||
|
def del_tags(tags: list[str]):
|
||||||
|
for t in tags:
|
||||||
|
TAGS.discard(t)
|
||||||
|
|
||||||
|
def get_tags_str() -> str:
|
||||||
|
return "+".join(TAGS) if TAGS else "(нет тегов)"
|
||||||
|
|
||||||
|
def get_tags() -> str:
|
||||||
|
return "+".join(TAGS) if TAGS else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def get_url():
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.firefox.launch(headless=True)
|
||||||
|
page = await browser.new_page(user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
tags = get_tags()
|
||||||
|
pid = randint(1, MAXIMUM)
|
||||||
|
|
||||||
|
# Формируем корректный URL
|
||||||
|
if tags:
|
||||||
|
url_page = f"{URL}&tags={tags}&pid={pid}"
|
||||||
|
else:
|
||||||
|
url_page = f"{URL}&pid={pid}"
|
||||||
|
|
||||||
|
await page.goto(url_page, timeout=5000)
|
||||||
|
|
||||||
|
# Ищем блок с картинками
|
||||||
|
block = await page.query_selector(".image-list")
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
|
||||||
|
spans = await block.query_selector_all("span")
|
||||||
|
if not spans:
|
||||||
|
continue
|
||||||
|
|
||||||
|
link_el = await choice(spans).query_selector("a")
|
||||||
|
if not link_el:
|
||||||
|
continue
|
||||||
|
|
||||||
|
href = await link_el.get_attribute("href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await page.goto(f"{BASE_URL}{href}", timeout=30000)
|
||||||
|
|
||||||
|
flexi = await page.query_selector(".flexi")
|
||||||
|
if not flexi:
|
||||||
|
continue
|
||||||
|
|
||||||
|
img_el = await flexi.query_selector("img")
|
||||||
|
if not img_el:
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = await img_el.get_attribute("src")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
return url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[get_url ERROR] {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
# Пример использования
|
||||||
|
async def main():
|
||||||
|
result = await get_url()
|
||||||
|
print("Result URL:", result)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto, Message, CallbackQuery
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
|
||||||
|
from aiogram import F
|
||||||
|
from logging import getLogger
|
||||||
|
from storage.message_storage import save_message
|
||||||
|
from .get_post import get_url, add_tags, del_tags, get_tags_str
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_keyboard():
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="Следующее фото", callback_data="next")]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state, bot: Bot):
|
||||||
|
|
||||||
|
@dp.message(Command("rule34"))
|
||||||
|
async def rule34(message: Message):
|
||||||
|
msg = await message.answer_photo(
|
||||||
|
photo= await get_url(),
|
||||||
|
caption="Вот фото 📷",
|
||||||
|
reply_markup=get_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
# сохраняем id сообщения, если нужно
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
@dp.callback_query(F.data == "next")
|
||||||
|
async def next_photo(callback: CallbackQuery):
|
||||||
|
for attempt in range(3): # максимум 3 попытки
|
||||||
|
try:
|
||||||
|
media = InputMediaPhoto(
|
||||||
|
media=await get_url(),
|
||||||
|
caption=f"Новое фото 🌄 (попытка {attempt + 1})"
|
||||||
|
)
|
||||||
|
await callback.message.edit_media(
|
||||||
|
media=media,
|
||||||
|
reply_markup=get_keyboard()
|
||||||
|
)
|
||||||
|
break # успех — выходим из цикла
|
||||||
|
|
||||||
|
except TelegramRetryAfter as e:
|
||||||
|
# Telegram сказал подождать e.retry_after секунд
|
||||||
|
logger.warning(f"Flood control: ждем {e.retry_after} сек")
|
||||||
|
await asyncio.sleep(e.retry_after)
|
||||||
|
continue # пробуем снова
|
||||||
|
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
logger.warning(f"Ошибка при загрузке фото (попытка {attempt + 1}): {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
else:
|
||||||
|
# если все попытки неудачные
|
||||||
|
logger.error("Не удалось загрузить фото после 3 попыток")
|
||||||
|
|
||||||
|
# закрываем "часики" в любом случае
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
@dp.message(Command("addteg"))
|
||||||
|
async def cmd_addteg(message: Message):
|
||||||
|
# Разбиваем текст после команды на теги
|
||||||
|
parts = message.text.split()[1:]
|
||||||
|
if not parts:
|
||||||
|
await message.answer("❌ Укажи теги через пробел: /addteg tag1 tag2")
|
||||||
|
return
|
||||||
|
add_tags(parts)
|
||||||
|
await message.answer(f"✅ Добавлены теги: {', '.join(parts)}\nТекущие: {get_tags_str()}")
|
||||||
|
|
||||||
|
@dp.message(Command("delteg"))
|
||||||
|
async def cmd_delteg(message: Message):
|
||||||
|
parts = message.text.split()[1:]
|
||||||
|
if not parts:
|
||||||
|
await message.answer("❌ Укажи теги для удаления: /delteg tag1 tag2")
|
||||||
|
return
|
||||||
|
del_tags(parts)
|
||||||
|
await message.answer(f"🗑 Удалены теги: {', '.join(parts)}\nТекущие: {get_tags_str()}")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from config import Config
|
||||||
|
import ssl
|
||||||
|
import certifi
|
||||||
|
from aiogram.types import BufferedInputFile
|
||||||
|
from utils.antispam import admin_required
|
||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message
|
||||||
|
from models.state import BotState
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from logging import getLogger
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
API_URL = "http://127.0.0.1:7700/speak"
|
||||||
|
|
||||||
|
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("vadmin"))
|
||||||
|
@admin_required(0)
|
||||||
|
async def vadmin(message: Message):
|
||||||
|
parts = message.text.split(maxsplit=1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
await message.reply("❗ Укажи текст после /vadmin")
|
||||||
|
return
|
||||||
|
phrase = parts[1]
|
||||||
|
|
||||||
|
# URL с параметрами модели и голоса
|
||||||
|
url = f"{Config.DEEPGRAM_TTS_URL}model=aura-2-andromeda-en"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Token {Config.DEEPGRAM_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# В JSON только text
|
||||||
|
payload = {"text": phrase}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
url, headers=headers, json=payload, ssl=ssl_context
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
await message.reply(
|
||||||
|
f"Ошибка генерации аудио: {resp.status} {error_text}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
audio_bytes = await resp.read()
|
||||||
|
audio_file = BufferedInputFile(audio_bytes, filename="speech.wav")
|
||||||
|
|
||||||
|
# Рассылка в чаты
|
||||||
|
for chat_id in Config.CHAT_IDS:
|
||||||
|
try:
|
||||||
|
await bot.send_audio(chat_id, audio=audio_file, caption="🎙 Текст")
|
||||||
|
except Exception as e:
|
||||||
|
await message.reply(f"Не удалось отправить в {chat_id}: {e}")
|
||||||
|
|
||||||
|
await message.reply("✅ Озвучка разослана.")
|
||||||
|
|
||||||
|
@dp.message(Command("admin"))
|
||||||
|
@admin_required(0)
|
||||||
|
async def admin(message: Message):
|
||||||
|
raw_text = message.text or message.caption
|
||||||
|
if not raw_text and not (
|
||||||
|
message.photo or message.document or message.audio or message.video
|
||||||
|
):
|
||||||
|
await message.reply(
|
||||||
|
"❌ Укажи текст или прикрепи файл/медиа: /admin <сообщение>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отрезаем саму команду (/admin)
|
||||||
|
args = raw_text.split(maxsplit=1) if raw_text else []
|
||||||
|
text_to_send = args[1] if len(args) > 1 else ""
|
||||||
|
|
||||||
|
for chat_id in Config.CHAT_IDS:
|
||||||
|
try:
|
||||||
|
if message.photo:
|
||||||
|
# Фото
|
||||||
|
photo = message.photo[-1].file_id
|
||||||
|
await bot.send_photo(chat_id, photo, caption=text_to_send)
|
||||||
|
|
||||||
|
elif message.document:
|
||||||
|
# Документ
|
||||||
|
await bot.send_document(
|
||||||
|
chat_id, message.document.file_id, caption=text_to_send
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message.audio:
|
||||||
|
# Аудио (музыка)
|
||||||
|
await bot.send_audio(
|
||||||
|
chat_id, message.audio.file_id, caption=text_to_send
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message.video:
|
||||||
|
# Видео
|
||||||
|
await bot.send_video(
|
||||||
|
chat_id, message.video.file_id, caption=text_to_send
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Только текст
|
||||||
|
await bot.send_message(chat_id, text_to_send)
|
||||||
|
|
||||||
|
logger.info(f"Сообщение отправлено в чат {chat_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке в чат {chat_id}: {e}")
|
||||||
|
|
||||||
|
await message.answer("✅ Сообщение отправлено.")
|
||||||
|
|
||||||
|
@dp.message(Command("iadmin"))
|
||||||
|
@admin_required(0)
|
||||||
|
async def id_admin(message: Message):
|
||||||
|
raw_text = message.text or message.caption
|
||||||
|
if not raw_text and not (
|
||||||
|
message.photo or message.document or message.audio or message.video
|
||||||
|
):
|
||||||
|
await message.reply(
|
||||||
|
"❌ Укажи ID чата и текст или прикрепи файл/медиа: /iadmin <chat_id> <сообщение>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отрезаем саму команду (/iadmin)
|
||||||
|
args = raw_text.split(maxsplit=2) if raw_text else []
|
||||||
|
if len(args) < 2:
|
||||||
|
await message.reply("❌ Укажи ID чата: /iadmin <chat_id> <сообщение>")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
chat_id = int(args[1]) # первый аргумент — ID чата
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("❌ Неверный формат chat_id")
|
||||||
|
return
|
||||||
|
|
||||||
|
text_to_send = args[2] if len(args) > 2 else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if message.photo:
|
||||||
|
# Фото
|
||||||
|
photo = message.photo[-1].file_id
|
||||||
|
await bot.send_photo(chat_id, photo, caption=text_to_send)
|
||||||
|
|
||||||
|
elif message.document:
|
||||||
|
# Документ
|
||||||
|
await bot.send_document(
|
||||||
|
chat_id, message.document.file_id, caption=text_to_send
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message.audio:
|
||||||
|
# Аудио (музыка)
|
||||||
|
await bot.send_audio(
|
||||||
|
chat_id, message.audio.file_id, caption=text_to_send
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message.video:
|
||||||
|
# Видео
|
||||||
|
await bot.send_video(
|
||||||
|
chat_id, message.video.file_id, caption=text_to_send
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Только текст
|
||||||
|
await bot.send_message(chat_id, text_to_send, parse_mode="Markdown")
|
||||||
|
|
||||||
|
logger.info(f"Сообщение отправлено в чат {chat_id}")
|
||||||
|
await message.answer("✅ Сообщение отправлено.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке в чат {chat_id}: {e}")
|
||||||
|
await message.answer(f"❌ Ошибка при отправке: {e}")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DIR = "/Users/mac/myfirstprogramm/addons/todo/todo.db" # лучше указать полный путь
|
||||||
|
|
||||||
|
# создаём подключение
|
||||||
|
db = sqlite3.connect(DIR)
|
||||||
|
cursor = db.cursor()
|
||||||
|
|
||||||
|
# создаём таблицу, если её ещё нет
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
who INTEGER NOT NULL,
|
||||||
|
task TEXT NOT NULL,
|
||||||
|
due_date TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# функция для добавления задачи
|
||||||
|
def add_task(who: int, task: str, due_date: str):
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO tasks (who, task, due_date) VALUES (?, ?, ?)",
|
||||||
|
(who, task, due_date)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# функция для получения всех задач пользователя
|
||||||
|
def get_tasks(who: int):
|
||||||
|
cursor.execute("SELECT id, task, due_date FROM tasks WHERE who = ?", (who,))
|
||||||
|
return cursor.fetchall()
|
||||||
|
|
||||||
|
# функция для удаления всех задач пользователя
|
||||||
|
def delete_tasks_by_who(who: int):
|
||||||
|
cursor.execute("DELETE FROM tasks WHERE who = ?", (who,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# пример использования
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# добавляем задачи
|
||||||
|
add_task(123456789, "Сделать бота", datetime.now().strftime("%d.%m.%Y %H:%M:%S"))
|
||||||
|
add_task(123456789, "Проверить базу", datetime.now().strftime("%d.%m.%Y %H:%M:%S"))
|
||||||
|
|
||||||
|
print("Задачи до удаления:")
|
||||||
|
tasks = get_tasks(123456789)
|
||||||
|
for t in tasks:
|
||||||
|
print(t)
|
||||||
|
|
||||||
|
# удаляем все задачи пользователя
|
||||||
|
delete_tasks_by_who(123456789)
|
||||||
|
|
||||||
|
print("\nЗадачи после удаления:")
|
||||||
|
tasks = get_tasks(123456789)
|
||||||
|
for t in tasks:
|
||||||
|
print(t)
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.types import Message
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
import logging
|
||||||
|
from .DB import get_tasks, add_task, delete_tasks_by_who
|
||||||
|
from storage.message_storage import save_message
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TodoForm(StatesGroup):
|
||||||
|
waiting_for_task = State()
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state, bot: Bot):
|
||||||
|
# /todos — начать добавление задачи
|
||||||
|
@dp.message(Command("todos"))
|
||||||
|
async def cmd_todos(message: Message, state: FSMContext):
|
||||||
|
save_message(message.chat.id, message.message_id) # сохраняем сообщение
|
||||||
|
await message.answer("Введите текст задачи:")
|
||||||
|
await state.set_state(TodoForm.waiting_for_task)
|
||||||
|
|
||||||
|
# обработка текста задачи
|
||||||
|
@dp.message(TodoForm.waiting_for_task)
|
||||||
|
async def process_task(message: Message, state: FSMContext):
|
||||||
|
save_message(message.chat.id, message.message_id) # сохраняем сообщение
|
||||||
|
task_text = message.text
|
||||||
|
user_id = message.from_user.id
|
||||||
|
try:
|
||||||
|
created_at = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
|
||||||
|
add_task(user_id, task_text, created_at)
|
||||||
|
reply = await message.answer(f"Задача сохранена ✅\n{task_text}")
|
||||||
|
save_message(reply.chat.id, reply.message_id) # сохраняем ответ бота
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при добавлении задачи: {e}")
|
||||||
|
reply = await message.answer("Не удалось сохранить задачу ❌")
|
||||||
|
save_message(reply.chat.id, reply.message_id)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
# /todor — показать список задач
|
||||||
|
@dp.message(Command("todor"))
|
||||||
|
async def cmd_todor(message: Message):
|
||||||
|
save_message(message.chat.id, message.message_id) # сохраняем сообщение
|
||||||
|
user_id = message.from_user.id
|
||||||
|
try:
|
||||||
|
tasks = get_tasks(user_id)
|
||||||
|
if not tasks:
|
||||||
|
reply = await message.answer("У вас пока нет задач 📝")
|
||||||
|
save_message(reply.chat.id, reply.message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "Ваши задачи:\n\n"
|
||||||
|
for tid, task, created_at in tasks:
|
||||||
|
text += f"• {task} (создана {created_at})\n"
|
||||||
|
reply = await message.answer(text)
|
||||||
|
save_message(reply.chat.id, reply.message_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при чтении задач: {e}")
|
||||||
|
reply = await message.answer("Не удалось получить список задач ❌")
|
||||||
|
save_message(reply.chat.id, reply.message_id)
|
||||||
|
|
||||||
|
@dp.message(Command("todod"))
|
||||||
|
async def del_todo(message: Message):
|
||||||
|
delete_tasks_by_who(message.from_user.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import requests
|
||||||
|
|
||||||
|
def get_weather(city: str):
|
||||||
|
return requests.get(f"https://api.weatherapi.com/v1/current.json?key=becad22574854f91aea163009261703&q={city}&aqi=no").json()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(get_weather("Minsk")['location']['name'])
|
||||||
|
print(get_weather("Minsk")['current']['temp_c'])
|
||||||
|
print(get_weather("Minsk")['current']['condition']["text"])
|
||||||
|
print(get_weather("Minsk")['current']['wind_kph'])
|
||||||
|
print(str(get_weather("Minsk")['current']['humidity'])+"%")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
from .api import get_weather
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state, bot: Bot):
|
||||||
|
@dp.message(Command("weather"))
|
||||||
|
async def weather(message: Message):
|
||||||
|
weather_data = get_weather("Minsk")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"🌍 *Погода в городе {weather_data['location']['name']}*\n\n"
|
||||||
|
f"🌡 *Температура:* {weather_data['current']['temp_c']}°C\n"
|
||||||
|
f"☁️ *Состояние:* {weather_data['current']['condition']['text']}\n"
|
||||||
|
f"💨 *Ветер:* {weather_data['current']['wind_kph']} км/ч\n"
|
||||||
|
f"💧 *Влажность:* {weather_data['current']['humidity']}%\n\n"
|
||||||
|
f"📅 *Обновлено:* {weather_data['current']['last_updated']}",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def register(dp, state, bot):
|
||||||
|
from . import handlers
|
||||||
|
|
||||||
|
handlers.register_handlers(dp, state, bot)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(dp):
|
||||||
|
# Здесь можно удалить хендлеры, если нужно
|
||||||
|
dp.message_handlers.handlers.clear()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import asyncio
|
||||||
|
import aiosqlite
|
||||||
|
from datetime import datetime
|
||||||
|
from logging import getLogger
|
||||||
|
from aiogram import Dispatcher, Bot
|
||||||
|
from config import Config
|
||||||
|
from models.state import BotState
|
||||||
|
from utils.antispam import save_message
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot) -> int:
|
||||||
|
async def init_db():
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
return
|
||||||
|
async with aiosqlite.connect(Config.DAYS_TO_DB_PATH) as db:
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS days_to_new_year (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
days INTEGER NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("База данных инициализирована")
|
||||||
|
|
||||||
|
async def save_days_to_db(user_id: int, days: int):
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
return
|
||||||
|
logger.debug(f"Сохраняем user_id={user_id}, days={days}")
|
||||||
|
async with aiosqlite.connect(Config.DAYS_TO_DB_PATH) as db:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT OR REPLACE INTO days_to_new_year (user_id, days, timestamp)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (int(user_id), int(days), datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Запись сохранена: user_id={user_id}, days={days}")
|
||||||
|
|
||||||
|
async def get_last_days(user_id: int) -> int | None:
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
return None
|
||||||
|
async with aiosqlite.connect(Config.DAYS_TO_DB_PATH) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT days FROM days_to_new_year WHERE user_id = ?", (int(user_id),)
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
async def days_to_new_years() -> int:
|
||||||
|
now = datetime.now()
|
||||||
|
new_year = datetime(now.year + 1, 1, 1)
|
||||||
|
delta = (new_year - now).days
|
||||||
|
logger.debug(f"До Нового года осталось {delta} дней")
|
||||||
|
return delta
|
||||||
|
|
||||||
|
async def days_to_summer() -> int:
|
||||||
|
"""Считает дни до 1 июня текущего года (или следующего, если уже лето прошло)."""
|
||||||
|
now = datetime.now()
|
||||||
|
summer = datetime(now.year, 6, 1)
|
||||||
|
if now >= summer:
|
||||||
|
summer = datetime(now.year + 1, 6, 1)
|
||||||
|
delta = (summer - now).days
|
||||||
|
logger.debug(f"До лета осталось {delta} дней")
|
||||||
|
return delta
|
||||||
|
|
||||||
|
async def days_to_session() -> int:
|
||||||
|
"""Считает дни до 1 июня текущего года (или следующего, если уже лето прошло)."""
|
||||||
|
now = datetime.now()
|
||||||
|
summer = datetime(2026, 7, 6)
|
||||||
|
if now >= summer:
|
||||||
|
logger.warning("days_to_session")
|
||||||
|
delta = (summer - now).days
|
||||||
|
logger.debug(f"До Сессии осталось {delta} дней")
|
||||||
|
return delta
|
||||||
|
|
||||||
|
async def send_days_to_new_years(user_id: int):
|
||||||
|
days_ny = await days_to_new_years()
|
||||||
|
days_summer = await days_to_summer()
|
||||||
|
days_session = await days_to_session()
|
||||||
|
last_days = await get_last_days(user_id)
|
||||||
|
|
||||||
|
if last_days == days_ny:
|
||||||
|
logger.info(f"user_id={user_id}: запись уже есть ({days_ny} дней), пропускаем")
|
||||||
|
return
|
||||||
|
|
||||||
|
await save_days_to_db(user_id, days_ny)
|
||||||
|
|
||||||
|
events = [
|
||||||
|
("🌞 До лета", days_summer),
|
||||||
|
("📚 До конца сессии", days_session),
|
||||||
|
("🎄 До Нового года", days_ny),
|
||||||
|
]
|
||||||
|
|
||||||
|
# сортировка по числу дней (от меньшего к большему)
|
||||||
|
events_sorted = sorted(events, key=lambda x: x[1])
|
||||||
|
|
||||||
|
message_text = "\n".join([f"{emoji} осталось {days} дней!" for emoji, days in events_sorted])
|
||||||
|
|
||||||
|
|
||||||
|
for chat_id in Config.CHAT_IDS:
|
||||||
|
try:
|
||||||
|
logger.info(f"Отправляем сообщение в чат {chat_id} для user_id={user_id}")
|
||||||
|
await bot.send_message(chat_id, message_text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке в чат {chat_id}: {e}")
|
||||||
|
|
||||||
|
async def periodic_task():
|
||||||
|
await init_db()
|
||||||
|
while True:
|
||||||
|
logger.info("Запуск цикла periodic_task")
|
||||||
|
for uid in Config.CHAT_IDS:
|
||||||
|
try:
|
||||||
|
msg = await send_days_to_new_years(int(uid))
|
||||||
|
if msg:
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Ошибка при обработке uid={uid}: {e}")
|
||||||
|
logger.info("Завершён цикл periodic_task, спим 24 часов")
|
||||||
|
await asyncio.sleep(86400) # каждые 24 часов
|
||||||
|
|
||||||
|
asyncio.create_task(periodic_task())
|
||||||
|
return 0
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from config import Config
|
||||||
|
from models.state import BotState
|
||||||
|
from addons.manager import AddonManager
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBot:
|
||||||
|
def __init__(self):
|
||||||
|
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)
|
||||||
|
schedule.register_handlers(self.dp, self.state)
|
||||||
|
# media.register_handlers(self.dp, self.state, self.bot)
|
||||||
|
# common.register_handlers(self.dp, self.state, self.bot)
|
||||||
|
|
||||||
|
# add addons
|
||||||
|
self.addons.load("download_mp3_to_youtube")
|
||||||
|
self.addons.load("id")
|
||||||
|
self.addons.load("send_message")
|
||||||
|
self.addons.load("poll")
|
||||||
|
self.addons.load("hello")
|
||||||
|
self.addons.load("weather")
|
||||||
|
self.addons.load("gpt")
|
||||||
|
self.addons.load("rule34")
|
||||||
|
# self.addons.load("todo")
|
||||||
|
self.addons.load("miniapps")
|
||||||
|
self.addons.load("x_days_to")
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Запуск бота"""
|
||||||
|
self.setup_handlers()
|
||||||
|
await self.dp.start_polling(self.bot)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
|
||||||
|
# API
|
||||||
|
API_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||||
|
Token = os.getenv("ACCESS_TOKEN")
|
||||||
|
DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
|
||||||
|
DEEPGRAM_AGENT_URL = "https://api.deepgram.com/v1/agent/think"
|
||||||
|
DEEPGRAM_TTS_URL = "https://api.deepgram.com/v1/speak?"
|
||||||
|
# 5575756416
|
||||||
|
BAN = [1]
|
||||||
|
|
||||||
|
if not API_TOKEN:
|
||||||
|
raise ValueError("❌ TELEGRAM_BOT_TOKEN не найден в переменных окружения!")
|
||||||
|
|
||||||
|
# Admins (user_id: уровень)
|
||||||
|
ADMINS: Dict[int, int] = {850906163: 0, 6394047531: 4, 1345058877: 3}
|
||||||
|
|
||||||
|
Names: Dict[int, str] = {850906163: "Ляпич", 6394047531: "Прокопович", 1345058877: "Сом"}
|
||||||
|
|
||||||
|
# Chats
|
||||||
|
CHAT_IDS = [-1003038389942]
|
||||||
|
GROUP_CHATS: Dict[str, int] = {
|
||||||
|
"30тс": -1003038389942,
|
||||||
|
"5Cа": 7571257031,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Settings
|
||||||
|
ANTISPAM_DELAY = 20
|
||||||
|
WATCHER_INTERVAL_SEC = 600
|
||||||
|
WATCHER_RANDOM_DELAY_MIN = 1
|
||||||
|
WATCHER_RANDOM_DELAY_MAX = 120
|
||||||
|
SCHEDULE_DRIVE_FOLDER_ID = os.getenv(
|
||||||
|
"SCHEDULE_DRIVE_FOLDER_ID", "1WhUFHGkS4qC_e84KRArF4ooXHJr8mL5T"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отключение логов и хранения (см. .env.example)
|
||||||
|
# DISABLE_PERSISTENCE=1 — выключает и логи, и все БД/файлы сразу
|
||||||
|
_NO_PERSISTENCE = _env_bool("DISABLE_PERSISTENCE")
|
||||||
|
DISABLE_LOGGING = (
|
||||||
|
_env_bool("DISABLE_LOGGING")
|
||||||
|
if os.getenv("DISABLE_LOGGING") is not None
|
||||||
|
else _NO_PERSISTENCE
|
||||||
|
)
|
||||||
|
DISABLE_STORAGE = (
|
||||||
|
_env_bool("DISABLE_STORAGE")
|
||||||
|
if os.getenv("DISABLE_STORAGE") is not None
|
||||||
|
else _NO_PERSISTENCE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Пути
|
||||||
|
LOG_FILE = Path("storage/log/bot.log")
|
||||||
|
DAYS_TO_DB_PATH = Path("addons/x_days_to/days_to_new_year.db")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
a = Config()
|
||||||
|
print(a.API_TOKEN)
|
||||||
|
print(a.ADMINS)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
from aiogram import types, Dispatcher, Bot
|
||||||
|
from aiogram.types import Message
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from config import Config
|
||||||
|
from models.state import BotState
|
||||||
|
from utils.antispam import admin_required, saving
|
||||||
|
from services.watcher_service import WatcherService
|
||||||
|
from storage.message_storage import load_messages, save_message, clear_messages
|
||||||
|
from logging import getLogger
|
||||||
|
from utils.analytics import create_statistics_text
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState, bot: Bot):
|
||||||
|
@dp.message(Command("log"))
|
||||||
|
@saving
|
||||||
|
@admin_required(3)
|
||||||
|
async def send_log(message: Message):
|
||||||
|
if Config.DISABLE_LOGGING:
|
||||||
|
await message.answer("📝 Логирование отключено (DISABLE_LOGGING=1).")
|
||||||
|
return
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
await message.answer("📝 Файл логов не ведётся (DISABLE_STORAGE=1).")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
log_file = types.FSInputFile(str(Config.LOG_FILE))
|
||||||
|
await message.answer_document(log_file, caption="📑 Логи бота")
|
||||||
|
except FileNotFoundError:
|
||||||
|
await message.answer("Файл логов пока не создан.")
|
||||||
|
|
||||||
|
@dp.message(Command("status"))
|
||||||
|
@saving
|
||||||
|
@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, get_process_usage
|
||||||
|
|
||||||
|
try:
|
||||||
|
if Config.DISABLE_LOGGING or Config.DISABLE_STORAGE:
|
||||||
|
await message.answer(
|
||||||
|
"📊 Аналитика по логам недоступна: логирование или хранение отключено в .env"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
stats = analyze_bot_logs(str(Config.LOG_FILE))
|
||||||
|
batt = await get_macbook_battery_level()
|
||||||
|
usage = await get_process_usage()
|
||||||
|
status_text = (
|
||||||
|
"🤖 СТАТУС БОТА\n"
|
||||||
|
"══════════════\n"
|
||||||
|
f"✅ Uptime: {stats.get('uptime_percentage', 0)}%\n"
|
||||||
|
f"⏱️ Слежка расписания: {'ВКЛ' if state.watcher_work else 'ВЫКЛ'}\n"
|
||||||
|
f"🔋 Уровень заряда: {batt}%\n"
|
||||||
|
f"🖥️ Загрузка цп: {usage['cpu_percent']}\n"
|
||||||
|
f"🧠 Загрузка оперативки: {usage['rss_mb']:.2f} MB\n"
|
||||||
|
)
|
||||||
|
await message.answer(status_text)
|
||||||
|
except Exception as e:
|
||||||
|
await message.answer(f"❌ Ошибка при проверке статуса: {str(e)}")
|
||||||
|
|
||||||
|
@dp.message(Command("analytics"))
|
||||||
|
@saving
|
||||||
|
@admin_required(1)
|
||||||
|
async def stat(message: Message):
|
||||||
|
from utils.analytics import analyze_bot_logs
|
||||||
|
|
||||||
|
if Config.DISABLE_LOGGING or Config.DISABLE_STORAGE:
|
||||||
|
await message.answer(
|
||||||
|
"📊 Аналитика по логам недоступна: логирование или хранение отключено в .env"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
stats = analyze_bot_logs(str(Config.LOG_FILE))
|
||||||
|
await message.answer(
|
||||||
|
create_statistics_text(stats), reply_to_message_id=message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@dp.message(Command("del"))
|
||||||
|
@admin_required(1)
|
||||||
|
async def delete_all_messages(message: Message):
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
await message.answer(
|
||||||
|
"📭 Хранение сообщений отключено (DISABLE_STORAGE=1).",
|
||||||
|
reply_to_message_id=message.message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
await bot.delete_message(chat_id, msg_id)
|
||||||
|
deleted += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось удалить {msg_id} в чате {chat_id}: {e}")
|
||||||
|
|
||||||
|
clear_messages()
|
||||||
|
sent = await message.answer(
|
||||||
|
f"✅ Удалено {deleted} сообщений (включая /rasp).",
|
||||||
|
reply_to_message_id=message.message_id,
|
||||||
|
)
|
||||||
|
save_message(sent.chat.id, sent.message_id)
|
||||||
|
|
||||||
|
@dp.message(Command("power"))
|
||||||
|
@saving
|
||||||
|
@admin_required(2)
|
||||||
|
async def power_control(message: types.Message):
|
||||||
|
args = message.text.split()
|
||||||
|
if len(args) < 2:
|
||||||
|
days = state.watcher_days_ahead
|
||||||
|
status = "включена" if state.watcher_work else "выключена"
|
||||||
|
await message.answer(f"⏱️ Слежка расписания: {status} (на {days} дн.)")
|
||||||
|
return
|
||||||
|
|
||||||
|
command = args[1].lower()
|
||||||
|
watcher_service = WatcherService(state, bot)
|
||||||
|
|
||||||
|
if command == "on":
|
||||||
|
# Проверяем, есть ли параметр количества дней
|
||||||
|
days = 1
|
||||||
|
if len(args) > 2:
|
||||||
|
try:
|
||||||
|
days = int(args[2])
|
||||||
|
if days < 1:
|
||||||
|
await message.answer("❌ Количество дней должно быть >= 1")
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Неверный формат дней. Используйте: /power on 3")
|
||||||
|
return
|
||||||
|
|
||||||
|
state.watcher_days_ahead = days
|
||||||
|
if not state.watcher_work:
|
||||||
|
await watcher_service.start()
|
||||||
|
await message.answer(f"✅ Слежка расписания включена (на {days} дн.)")
|
||||||
|
else:
|
||||||
|
await message.answer(f"✅ Количество дней изменено на {days} дн.")
|
||||||
|
elif command == "off" and state.watcher_work:
|
||||||
|
await watcher_service.stop()
|
||||||
|
await message.answer("❌ Слежка расписания выключена")
|
||||||
|
else:
|
||||||
|
await message.answer("❌ Неверная команда")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from aiogram import types, Dispatcher
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from models.state import BotState
|
||||||
|
from services.schedule_service import ScheduleService
|
||||||
|
from utils.antispam import is_chat_spam, saving
|
||||||
|
from storage.message_storage import save_message
|
||||||
|
from storage.users_storage import set_group, get_group
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
VALID_GROUPS = [
|
||||||
|
"603Т", "33мд", "32мд", "31тс", "30тс", "29то", "28то", "27д", "26д", "25тм", "24тм",
|
||||||
|
"23д", "22мд", "21мд", "20тс", "19тс", "18то", "17то", "16д", "15д", "14тм", "13тм",
|
||||||
|
"12д", "11мд", "10мд", "8тс", "7то", "7Ст", "6то", "6Сб", "5д", "5Cа", "4Сб", "3тм",
|
||||||
|
"3Са", "2тм", "2Cб", "1Са", "600Р", "601Р", "602д"
|
||||||
|
]
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher, state: BotState):
|
||||||
|
@dp.message(Command("rasp"))
|
||||||
|
@saving
|
||||||
|
async def send_schedule(message: types.Message):
|
||||||
|
"""Отправка расписания (текст)"""
|
||||||
|
if is_chat_spam(message.chat.id, state):
|
||||||
|
await message.answer("НЕ СПАМЬТЕ!!!")
|
||||||
|
return
|
||||||
|
|
||||||
|
args = message.text.split(maxsplit=2)
|
||||||
|
# Определяем группу
|
||||||
|
if len(args) > 1:
|
||||||
|
group = args[1].strip()
|
||||||
|
else:
|
||||||
|
group = get_group(message.from_user.id)
|
||||||
|
|
||||||
|
# Определяем смещение по дню
|
||||||
|
day_offset = int(args[2]) if len(args) > 2 and args[2].isdigit() else 0
|
||||||
|
|
||||||
|
schedule_service = ScheduleService()
|
||||||
|
text, url, day, month = await schedule_service.get_schedule(group, day_offset)
|
||||||
|
|
||||||
|
msg = await message.answer(text, parse_mode="HTML")
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
@dp.message(Command("prasp"))
|
||||||
|
@saving
|
||||||
|
async def send_pschedule(message: types.Message):
|
||||||
|
"""Отправка расписания"""
|
||||||
|
if is_chat_spam(message.chat.id, state):
|
||||||
|
await message.answer("НЕ СПАМЬТЕ!!!")
|
||||||
|
return
|
||||||
|
|
||||||
|
args = message.text.split(maxsplit=2)
|
||||||
|
if len(args) > 1:
|
||||||
|
group = args[1].strip()
|
||||||
|
else:
|
||||||
|
group = get_group(message.from_user.id)
|
||||||
|
day_offset = int(args[2]) if len(args) > 2 and args[2].isdigit() else 0
|
||||||
|
|
||||||
|
schedule_service = ScheduleService()
|
||||||
|
clip_png, url, day, mouth = await schedule_service.get_pschedule(
|
||||||
|
group, day_offset
|
||||||
|
)
|
||||||
|
|
||||||
|
if clip_png:
|
||||||
|
save_message(message.chat.id, message.message_id)
|
||||||
|
|
||||||
|
msg = await message.answer_photo(
|
||||||
|
types.BufferedInputFile(clip_png, filename=f"{group}.png"),
|
||||||
|
caption=f"Расписание для {group} на {day}.{mouth:02d}",
|
||||||
|
)
|
||||||
|
save_message(message.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
state.file_id_cache[group.lower()] = msg.photo[-1].file_id
|
||||||
|
else:
|
||||||
|
await message.answer(f"Не удалось найти расписание для {group}")
|
||||||
|
|
||||||
|
@dp.message(Command("set"))
|
||||||
|
@saving
|
||||||
|
async def set(message: types.Message):
|
||||||
|
# создаём клавиатуру
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for group in VALID_GROUPS:
|
||||||
|
builder.button(text=group, callback_data=f"set_group:{group}")
|
||||||
|
builder.adjust(5) # количество кнопок в строке
|
||||||
|
|
||||||
|
msg = await message.answer(
|
||||||
|
"Выбери группу из списка:",
|
||||||
|
reply_markup=builder.as_markup()
|
||||||
|
)
|
||||||
|
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
|
||||||
|
@dp.callback_query(lambda c: c.data.startswith("set_group:"))
|
||||||
|
async def process_group_choice(callback: types.CallbackQuery):
|
||||||
|
group = callback.data.split(":")[1]
|
||||||
|
set_group(callback.from_user.id, group)
|
||||||
|
|
||||||
|
# редактируем сообщение: убираем клавиатуру
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=None)
|
||||||
|
|
||||||
|
msg = await callback.message.answer(f"✅ Группа установлена: {group}")
|
||||||
|
save_message(msg.chat.id, msg.message_id)
|
||||||
|
await callback.answer()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from asyncio import run
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from bot.core import TelegramBot
|
||||||
|
from config import Config
|
||||||
|
from utils.logging_config import setup_logging
|
||||||
|
|
||||||
|
setup_logging()
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Основная функция запуска"""
|
||||||
|
try:
|
||||||
|
bot = TelegramBot()
|
||||||
|
logger.info("Бот запускается...")
|
||||||
|
await bot.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при запуске бота: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info("Бот остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(main())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ca7463d0df0fc143": "captainerisnebula-12b-chimera-v1.1-iq-imatrix",
|
||||||
|
"b6886fc896f68593": "liquid/lfm2.5-1.2b",
|
||||||
|
"43efb8b5d51c38d7": "tiefighter-holodeck-holomax-mythomax-f1-v1-compos-20b",
|
||||||
|
"4f9b128b9b567451": "text-embedding-nomic-embed-text-v1.5",
|
||||||
|
"1ce7277325bb3050": "google/gemma-3n-e4b",
|
||||||
|
"390b8888e1038e17": "google/gemma-3-12b"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from asyncio import Task
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BotState:
|
||||||
|
"""Состояние бота"""
|
||||||
|
|
||||||
|
last_chat_time: Dict[int, str] = None
|
||||||
|
last_pinned: Dict[str, int] = None
|
||||||
|
watcher_work: bool = False
|
||||||
|
watcher_task: Optional[Task] = None
|
||||||
|
watcher_days_ahead: int = 1
|
||||||
|
file_id_cache: Dict[str, str] = None
|
||||||
|
last_day: Dict[str, int] = None
|
||||||
|
last_clip_hash: Dict[str, str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.last_chat_time is None:
|
||||||
|
self.last_chat_time = {}
|
||||||
|
if self.last_pinned is None:
|
||||||
|
self.last_pinned = {}
|
||||||
|
if self.file_id_cache is None:
|
||||||
|
self.file_id_cache = {}
|
||||||
|
if self.last_day is None:
|
||||||
|
self.last_day = {}
|
||||||
|
if self.last_clip_hash is None:
|
||||||
|
self.last_clip_hash = {}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
aenum==3.1.16
|
||||||
|
aiofiles==24.1.0
|
||||||
|
aiogram==3.22.0
|
||||||
|
aiohappyeyeballs==2.6.1
|
||||||
|
aiohttp==3.12.15
|
||||||
|
aiosignal==1.4.0
|
||||||
|
aiosqlite==0.21.0
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.11.0
|
||||||
|
attrs==25.3.0
|
||||||
|
beautifulsoup4==4.14.2
|
||||||
|
bs4==0.0.2
|
||||||
|
certifi==2025.8.3
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.3
|
||||||
|
click==8.3.0
|
||||||
|
cryptography==46.0.2
|
||||||
|
dataclasses-json==0.6.7
|
||||||
|
deepgram-sdk==3.11.0
|
||||||
|
deprecation==2.1.0
|
||||||
|
distro==1.9.0
|
||||||
|
dotenv==0.9.9
|
||||||
|
dropbox==12.0.2
|
||||||
|
filelock==3.20.0
|
||||||
|
frozenlist==1.7.0
|
||||||
|
fsspec==2025.9.0
|
||||||
|
greenlet==3.2.4
|
||||||
|
h11==0.16.0
|
||||||
|
hf-xet==1.1.10
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
huggingface-hub==0.35.3
|
||||||
|
idna==3.10
|
||||||
|
Jinja2==3.1.6
|
||||||
|
jiter==0.11.1
|
||||||
|
joblib==1.5.2
|
||||||
|
lxml==6.0.2
|
||||||
|
magic-filter==1.0.12
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
marshmallow==3.26.1
|
||||||
|
mpmath==1.3.0
|
||||||
|
msal==1.34.0
|
||||||
|
multidict==6.6.4
|
||||||
|
mutagen==1.47.0
|
||||||
|
mypy_extensions==1.1.0
|
||||||
|
networkx==3.5
|
||||||
|
numpy==2.3.4
|
||||||
|
ollama==0.6.0
|
||||||
|
openai==2.5.0
|
||||||
|
packaging==25.0
|
||||||
|
pillow==12.0.0
|
||||||
|
playwright==1.55.0
|
||||||
|
ply==3.11
|
||||||
|
propcache==0.3.2
|
||||||
|
pycparser==2.23
|
||||||
|
pymupdf==1.27.2.3
|
||||||
|
pypdf==6.11.0
|
||||||
|
pydantic==2.11.10
|
||||||
|
pydantic_core==2.33.2
|
||||||
|
pyee==13.0.0
|
||||||
|
PyJWT==2.10.1
|
||||||
|
python-dotenv==1.1.1
|
||||||
|
PyYAML==6.0.3
|
||||||
|
regex==2025.9.18
|
||||||
|
requests==2.32.5
|
||||||
|
ruff==0.14.6
|
||||||
|
sacremoses==0.1.1
|
||||||
|
safetensors==0.6.2
|
||||||
|
sentencepiece==0.2.1
|
||||||
|
setuptools==80.9.0
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
soupsieve==2.8
|
||||||
|
stone==3.3.1
|
||||||
|
sympy==1.14.0
|
||||||
|
tokenizers==0.22.1
|
||||||
|
torch==2.9.0
|
||||||
|
tqdm==4.67.1
|
||||||
|
transformers==4.57.1
|
||||||
|
typing-inspect==0.9.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.5.0
|
||||||
|
websockets==15.0.1
|
||||||
|
yarl==1.20.1
|
||||||
|
yt-dlp==2025.10.22
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import certifi
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DRIVE_FOLDER_EMBED = (
|
||||||
|
"https://drive.google.com/embeddedfolderview?id={folder_id}#list"
|
||||||
|
)
|
||||||
|
DRIVE_DOWNLOAD_URL = "https://drive.google.com/uc?export=download&id={file_id}"
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_connector() -> aiohttp.TCPConnector:
|
||||||
|
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
return aiohttp.TCPConnector(ssl=ssl_context)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DriveScheduleFile:
|
||||||
|
file_id: str
|
||||||
|
name: str
|
||||||
|
schedule_date: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DriveScheduleSource:
|
||||||
|
def __init__(self, folder_id: str):
|
||||||
|
self.folder_id = folder_id
|
||||||
|
self._files_cache: Optional[List[DriveScheduleFile]] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_filename_date(name: str) -> Optional[datetime]:
|
||||||
|
match = re.match(
|
||||||
|
r"^(\d{2})\.(\d{2})\.(\d{2})\s+по\s+учащимся\.pdf$",
|
||||||
|
name.strip(),
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
day, month, year = map(int, match.groups())
|
||||||
|
return datetime(2000 + year, month, day)
|
||||||
|
|
||||||
|
async def list_student_schedules(self, force_refresh: bool = False) -> List[DriveScheduleFile]:
|
||||||
|
if self._files_cache is not None and not force_refresh:
|
||||||
|
return self._files_cache
|
||||||
|
|
||||||
|
url = DRIVE_FOLDER_EMBED.format(folder_id=self.folder_id)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
connector=_drive_connector(),
|
||||||
|
) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
html = await resp.text()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
files: List[DriveScheduleFile] = []
|
||||||
|
|
||||||
|
for entry in soup.select("div.flip-entry"):
|
||||||
|
entry_id = entry.get("id", "")
|
||||||
|
if not entry_id.startswith("entry-"):
|
||||||
|
continue
|
||||||
|
file_id = entry_id.removeprefix("entry-")
|
||||||
|
title_el = entry.select_one(".flip-entry-title")
|
||||||
|
if not title_el:
|
||||||
|
continue
|
||||||
|
name = title_el.get_text(strip=True)
|
||||||
|
schedule_date = self._parse_filename_date(name)
|
||||||
|
if schedule_date is None:
|
||||||
|
continue
|
||||||
|
files.append(DriveScheduleFile(file_id=file_id, name=name, schedule_date=schedule_date))
|
||||||
|
|
||||||
|
files.sort(key=lambda item: item.schedule_date)
|
||||||
|
self._files_cache = files
|
||||||
|
return files
|
||||||
|
|
||||||
|
async def find_for_date(self, target: datetime) -> Optional[DriveScheduleFile]:
|
||||||
|
files = await self.list_student_schedules()
|
||||||
|
for item in reversed(files):
|
||||||
|
if (
|
||||||
|
item.schedule_date.day == target.day
|
||||||
|
and item.schedule_date.month == target.month
|
||||||
|
and item.schedule_date.year == target.year
|
||||||
|
):
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def download_pdf(self, file_id: str) -> bytes:
|
||||||
|
url = DRIVE_DOWNLOAD_URL.format(file_id=file_id)
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
connector=_drive_connector(),
|
||||||
|
) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return await resp.read()
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from html import escape
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import fitz
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from services.drive_schedule_source import DriveScheduleSource
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
BOUNDARY = r"[^0-9A-Za-zА-Яа-яЁё]"
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleService:
|
||||||
|
def __init__(self):
|
||||||
|
folder_id = getattr(Config, "SCHEDULE_DRIVE_FOLDER_ID", None) or (
|
||||||
|
"1WhUFHGkS4qC_e84KRArF4ooXHJr8mL5T"
|
||||||
|
)
|
||||||
|
self.drive = DriveScheduleSource(folder_id)
|
||||||
|
self._pdf_cache: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
def _resolve_target_date(self, day_offset: int = 0) -> datetime:
|
||||||
|
target = datetime.now()
|
||||||
|
if day_offset == 0:
|
||||||
|
if target.hour >= 12:
|
||||||
|
target += timedelta(days=1)
|
||||||
|
if target.weekday() == 6:
|
||||||
|
target += timedelta(days=1)
|
||||||
|
else:
|
||||||
|
target = target.replace(day=int(day_offset))
|
||||||
|
return target.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_target_date(self, day_offset: int = 0) -> datetime:
|
||||||
|
return (datetime.now() + timedelta(days=day_offset)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_pdf_for_date(
|
||||||
|
self, day_offset: int = 0
|
||||||
|
) -> Tuple[Optional[bytes], Optional[str], int, int]:
|
||||||
|
target = self._resolve_target_date(day_offset)
|
||||||
|
day, month = target.day, target.month
|
||||||
|
|
||||||
|
drive_file = await self.drive.find_for_date(target)
|
||||||
|
if not drive_file:
|
||||||
|
return None, None, day, month
|
||||||
|
|
||||||
|
if drive_file.file_id not in self._pdf_cache:
|
||||||
|
self._pdf_cache[drive_file.file_id] = await self.drive.download_pdf(
|
||||||
|
drive_file.file_id
|
||||||
|
)
|
||||||
|
|
||||||
|
url = f"https://drive.google.com/file/d/{drive_file.file_id}/view"
|
||||||
|
return self._pdf_cache[drive_file.file_id], url, day, month
|
||||||
|
|
||||||
|
async def _load_pdf_for_watcher(
|
||||||
|
self, day_offset: int = 1
|
||||||
|
) -> Tuple[Optional[bytes], Optional[str], int, int]:
|
||||||
|
target = self._next_target_date(day_offset)
|
||||||
|
day, month = target.day, target.month
|
||||||
|
|
||||||
|
drive_file = await self.drive.find_for_date(target)
|
||||||
|
if not drive_file:
|
||||||
|
return None, None, day, month
|
||||||
|
|
||||||
|
if drive_file.file_id not in self._pdf_cache:
|
||||||
|
self._pdf_cache[drive_file.file_id] = await self.drive.download_pdf(
|
||||||
|
drive_file.file_id
|
||||||
|
)
|
||||||
|
|
||||||
|
url = f"https://drive.google.com/file/d/{drive_file.file_id}/view"
|
||||||
|
return self._pdf_cache[drive_file.file_id], url, day, month
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def exact_group_regex(group: str) -> re.Pattern:
|
||||||
|
pattern = rf"(^|{BOUNDARY}){re.escape(group)}({BOUNDARY}|$)"
|
||||||
|
return re.compile(pattern, re.IGNORECASE)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_pdf_lines(pdf_bytes: bytes) -> List[str]:
|
||||||
|
reader = PdfReader(io.BytesIO(pdf_bytes))
|
||||||
|
lines: List[str] = []
|
||||||
|
for page in reader.pages:
|
||||||
|
text = page.extract_text() or ""
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_group_lines(lines: List[str], group: str) -> List[str]:
|
||||||
|
regex = ScheduleService.exact_group_regex(group)
|
||||||
|
schedule_lines: List[str] = []
|
||||||
|
found_group = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if not found_group:
|
||||||
|
if regex.search(line):
|
||||||
|
found_group = True
|
||||||
|
schedule_lines.append(line)
|
||||||
|
else:
|
||||||
|
if "-----" in line or "+----" in line:
|
||||||
|
break
|
||||||
|
schedule_lines.append(line)
|
||||||
|
|
||||||
|
return schedule_lines
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_schedule_missing(text: str) -> bool:
|
||||||
|
lowered = text.lower()
|
||||||
|
return "не найдено" in lowered or "не опубликовано" in lowered
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_schedule_html(day: int, schedule_lines: List[str]) -> str:
|
||||||
|
body_lines = []
|
||||||
|
for line in schedule_lines:
|
||||||
|
formatted = line.replace("¦", "│").replace(" ", " ").strip()
|
||||||
|
if formatted:
|
||||||
|
body_lines.append(formatted)
|
||||||
|
body = escape("\n".join(body_lines))
|
||||||
|
return f"📅 Расписание для {day} числа:\n<pre>{body}</pre>"
|
||||||
|
|
||||||
|
async def is_published_for(self, day_offset: int = 0) -> bool:
|
||||||
|
target = self._next_target_date(day_offset)
|
||||||
|
return await self.drive.find_for_date(target) is not None
|
||||||
|
|
||||||
|
async def get_schedule(
|
||||||
|
self, group: str, day_offset: int = 0
|
||||||
|
) -> Tuple[str, str, int, int]:
|
||||||
|
pdf_bytes, url, day, month = await self._load_pdf_for_date(day_offset)
|
||||||
|
|
||||||
|
folder_url = "https://drive.google.com/drive/folders/" + self.drive.folder_id
|
||||||
|
|
||||||
|
if not pdf_bytes:
|
||||||
|
result = (
|
||||||
|
f"⚠️ Расписание на {day:02d}.{month:02d} ещё не опубликовано "
|
||||||
|
f"в <a href=\"{folder_url}\">Google Drive</a>"
|
||||||
|
)
|
||||||
|
return result, folder_url, day, month
|
||||||
|
|
||||||
|
schedule_lines = self._parse_group_lines(
|
||||||
|
self._extract_pdf_lines(pdf_bytes), group
|
||||||
|
)
|
||||||
|
|
||||||
|
if not schedule_lines:
|
||||||
|
result = f"⚠️ Расписание для группы {escape(group)} на {day} число не найдено"
|
||||||
|
else:
|
||||||
|
result = self._format_schedule_html(day, schedule_lines)
|
||||||
|
|
||||||
|
return result, url or folder_url, day, month
|
||||||
|
|
||||||
|
async def get_pschedule(
|
||||||
|
self, group: str, day_offset: int = 0
|
||||||
|
) -> Tuple[Optional[bytes], str, int, int]:
|
||||||
|
pdf_bytes, url, day, month = await self._load_pdf_for_date(day_offset)
|
||||||
|
fallback_url = (
|
||||||
|
url
|
||||||
|
or "https://drive.google.com/drive/folders/" + self.drive.folder_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pdf_bytes:
|
||||||
|
return None, fallback_url, day, month
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||||
|
regex = self.exact_group_regex(group)
|
||||||
|
|
||||||
|
for page in doc:
|
||||||
|
line_items = []
|
||||||
|
page_dict = page.get_text("dict")
|
||||||
|
for block in page_dict.get("blocks", []):
|
||||||
|
if block.get("type") != 0:
|
||||||
|
continue
|
||||||
|
for line in block.get("lines", []):
|
||||||
|
text = "".join(span["text"] for span in line["spans"]).strip()
|
||||||
|
if text:
|
||||||
|
line_items.append((text, fitz.Rect(line["bbox"])))
|
||||||
|
|
||||||
|
found_group = False
|
||||||
|
rects: List[fitz.Rect] = []
|
||||||
|
for text, bbox in line_items:
|
||||||
|
if not found_group:
|
||||||
|
if regex.search(text):
|
||||||
|
found_group = True
|
||||||
|
rects.append(bbox)
|
||||||
|
else:
|
||||||
|
if "-----" in text or "+----" in text:
|
||||||
|
break
|
||||||
|
rects.append(bbox)
|
||||||
|
|
||||||
|
if not rects:
|
||||||
|
continue
|
||||||
|
|
||||||
|
clip = rects[0]
|
||||||
|
for rect in rects[1:]:
|
||||||
|
clip |= rect
|
||||||
|
clip.x0 = max(0, clip.x0 - 10)
|
||||||
|
clip.x1 = min(page.rect.width, clip.x1 + 150)
|
||||||
|
clip.y0 = max(0, clip.y0 - 5)
|
||||||
|
clip.y1 = min(page.rect.height, clip.y1 + 10)
|
||||||
|
|
||||||
|
pixmap = page.get_pixmap(clip=clip, matrix=fitz.Matrix(2, 2))
|
||||||
|
return pixmap.tobytes("png"), fallback_url, day, month
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении расписания из PDF: {e}")
|
||||||
|
|
||||||
|
return None, fallback_url, day, month
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import asyncio
|
||||||
|
from random import randint
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from aiogram import Bot, types
|
||||||
|
from config import Config
|
||||||
|
from logging import getLogger
|
||||||
|
from models.state import BotState
|
||||||
|
from services.schedule_service import ScheduleService
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WatcherService:
|
||||||
|
def __init__(self, state: BotState, bot: Bot):
|
||||||
|
self.state = state
|
||||||
|
self.bot = bot
|
||||||
|
self.schedule_service = ScheduleService()
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Запуск слежки"""
|
||||||
|
if self.state.watcher_work:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.state.watcher_work = True
|
||||||
|
self.state.watcher_task = asyncio.create_task(self._watcher_loop())
|
||||||
|
logger.info("Watcher запущен")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Остановка слежки"""
|
||||||
|
if not self.state.watcher_work:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.state.watcher_work = False
|
||||||
|
if self.state.watcher_task:
|
||||||
|
self.state.watcher_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.state.watcher_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Watcher остановлен")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _next_delay() -> int:
|
||||||
|
return Config.WATCHER_INTERVAL_SEC + randint(
|
||||||
|
Config.WATCHER_RANDOM_DELAY_MIN,
|
||||||
|
Config.WATCHER_RANDOM_DELAY_MAX,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_target_date_with_weekend_handling(days_ahead: int) -> datetime:
|
||||||
|
"""
|
||||||
|
Получить целевую дату с учетом выходных.
|
||||||
|
Если целевая дата - воскресенье, переносится на понедельник.
|
||||||
|
"""
|
||||||
|
target = (datetime.now() + timedelta(days=days_ahead)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
# weekday() returns 6 for Sunday
|
||||||
|
if target.weekday() == 6:
|
||||||
|
target += timedelta(days=1)
|
||||||
|
return target
|
||||||
|
|
||||||
|
async def _watcher_loop(self):
|
||||||
|
"""Основной цикл слежки за появлением PDF на Google Drive."""
|
||||||
|
while self.state.watcher_work:
|
||||||
|
try:
|
||||||
|
nothing_found = await self._check_all_groups()
|
||||||
|
if nothing_found:
|
||||||
|
delay = self._next_delay()
|
||||||
|
logger.info(f"PDF/расписание не найдено, следующая проверка через {delay} с")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
logger.info("Расписание найдено и отправлено, останавливаем watcher")
|
||||||
|
self.state.watcher_work = False
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в watcher_loop: {e}")
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
async def _check_all_groups(self) -> bool:
|
||||||
|
"""
|
||||||
|
Возвращает True, если расписание ещё недоступно ни для одной группы.
|
||||||
|
Возвращает False, если хотя бы одной группе отправили расписание.
|
||||||
|
"""
|
||||||
|
days_ahead = self.state.watcher_days_ahead
|
||||||
|
target = self._get_target_date_with_weekend_handling(days_ahead)
|
||||||
|
logger.info(
|
||||||
|
f"Проверяем Google Drive на расписание за {target.strftime('%d.%m.%Y')} "
|
||||||
|
f"(дней вперед: {days_ahead})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not await self.schedule_service.is_published_for(days_ahead):
|
||||||
|
return True
|
||||||
|
|
||||||
|
found_any = False
|
||||||
|
for group, chat_id in Config.GROUP_CHATS.items():
|
||||||
|
logger.info(
|
||||||
|
f"Проверяем расписание для {group} на {target.strftime('%d.%m.%Y')}"
|
||||||
|
)
|
||||||
|
if await self._check_group_schedule(group, chat_id, days_ahead):
|
||||||
|
found_any = True
|
||||||
|
|
||||||
|
return not found_any
|
||||||
|
|
||||||
|
async def _check_group_schedule(self, group: str, chat_id: int, days_ahead: int) -> bool:
|
||||||
|
target = self._get_target_date_with_weekend_handling(days_ahead)
|
||||||
|
text, url, data_day, data_month = await self.schedule_service.get_schedule(
|
||||||
|
group, target.day
|
||||||
|
)
|
||||||
|
if not self.schedule_service.is_schedule_missing(text):
|
||||||
|
msg = await self.bot.send_message(
|
||||||
|
chat_id,
|
||||||
|
(
|
||||||
|
f"🔔 Авто-расписание для {group} "
|
||||||
|
f"на {data_day:02d}.{data_month:02d}\n\n{text}"
|
||||||
|
),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.pin_chat_message(
|
||||||
|
chat_id, msg.message_id, disable_notification=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось закрепить сообщение в {chat_id}: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
png, url, data_day, data_month = await self.schedule_service.get_pschedule(
|
||||||
|
group, 0
|
||||||
|
)
|
||||||
|
if png:
|
||||||
|
await self.bot.send_photo(
|
||||||
|
chat_id,
|
||||||
|
types.BufferedInputFile(png, filename=f"{group}.png"),
|
||||||
|
caption=(
|
||||||
|
f"🔔 АВАРИЙНЫЙ РЕЖИМ\n\n"
|
||||||
|
f"Авто-расписание для {group} "
|
||||||
|
f"на {data_day:02d}.{data_month:02d}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
DIR = Path(__file__).resolve().parent / "message.db"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db = sqlite3.connect(DIR)
|
||||||
|
cursor = db.cursor()
|
||||||
|
|
||||||
|
# создаём таблицы (лучше добавить IF NOT EXISTS)
|
||||||
|
cursor.execute("""CREATE TABLE IF NOT EXISTS message (
|
||||||
|
chat_id INTEGER,
|
||||||
|
message_id INTEGER
|
||||||
|
)""")
|
||||||
|
|
||||||
|
cursor.execute("""CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id INTEGER,
|
||||||
|
user_group TEXT
|
||||||
|
)""")
|
||||||
|
|
||||||
|
# добавим тестовые данные
|
||||||
|
cursor.execute("INSERT INTO message VALUES (?, ?)", (1, 100))
|
||||||
|
cursor.execute("INSERT INTO users VALUES (?, ?)", (42, 'admin'))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# читаем данные
|
||||||
|
cursor.execute("SELECT * FROM message")
|
||||||
|
print("Message:", cursor.fetchall())
|
||||||
|
|
||||||
|
cursor.execute("SELECT * FROM users")
|
||||||
|
print("Users:", cursor.fetchall())
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return sqlite3.connect(DIR)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def save_message(chat_id: int, message_id: int):
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
return
|
||||||
|
if True:
|
||||||
|
return
|
||||||
|
from .DB import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
cur = db.cursor()
|
||||||
|
cur.execute("INSERT INTO message VALUES (?, ?)", (int(chat_id), int(message_id)))
|
||||||
|
db.commit()
|
||||||
|
cur.close()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def load_messages():
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
return []
|
||||||
|
if True:
|
||||||
|
return []
|
||||||
|
from .DB import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
cur = db.cursor()
|
||||||
|
cur.execute("SELECT * FROM message")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
db.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def clear_messages():
|
||||||
|
if Config.DISABLE_STORAGE:
|
||||||
|
return
|
||||||
|
if True:
|
||||||
|
return
|
||||||
|
from .DB import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
cur = db.cursor()
|
||||||
|
cur.execute("DELETE FROM message")
|
||||||
|
db.commit()
|
||||||
|
cur.close()
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from config import Config
|
||||||
|
|
||||||
|
_DEFAULT_GROUP = "30тс"
|
||||||
|
|
||||||
|
|
||||||
|
def save_user(user_id: int, group: str = _DEFAULT_GROUP):
|
||||||
|
from .DB import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
cur = db.cursor()
|
||||||
|
cur.execute("INSERT INTO users (user_id, user_group) VALUES (?, ?)", (user_id, group))
|
||||||
|
db.commit()
|
||||||
|
cur.close()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def set_group(user_id: int, group: str = _DEFAULT_GROUP):
|
||||||
|
from .DB import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
cur = db.cursor()
|
||||||
|
cur.execute("SELECT 1 FROM users WHERE user_id = ?", (user_id,))
|
||||||
|
exists = cur.fetchone()
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
cur.execute("UPDATE users SET user_group = ? WHERE user_id = ?", (group, user_id))
|
||||||
|
else:
|
||||||
|
cur.execute("INSERT INTO users (user_id, user_group) VALUES (?, ?)", (user_id, group))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
cur.close()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_group(user_id: int, default: str = _DEFAULT_GROUP) -> str:
|
||||||
|
from .DB import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
cur = db.cursor()
|
||||||
|
cur.execute("SELECT user_group FROM users WHERE user_id = ?", (user_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
group = row[0]
|
||||||
|
else:
|
||||||
|
cur.execute("INSERT INTO users (user_id, user_group) VALUES (?, ?)", (user_id, default))
|
||||||
|
db.commit()
|
||||||
|
group = default
|
||||||
|
cur.close()
|
||||||
|
db.close()
|
||||||
|
return group
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
from collections import Counter
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
import statistics
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_bot_logs(log_file_path="bot.log"):
|
||||||
|
"""
|
||||||
|
Анализирует логи бота и создает детальную статистику
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(log_file_path, "r", encoding="utf-8") as log:
|
||||||
|
lines = log.readlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"error": "Лог файл не найден"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Ошибка чтения файла: {str(e)}"}
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return {"error": "Лог файл пуст"}
|
||||||
|
|
||||||
|
# Основные счетчики
|
||||||
|
stats = {
|
||||||
|
"total_lines": len(lines),
|
||||||
|
"time_period": {},
|
||||||
|
"log_levels": Counter(),
|
||||||
|
"activities": Counter(),
|
||||||
|
"errors": Counter(),
|
||||||
|
"warnings": Counter(),
|
||||||
|
"user_commands": Counter(),
|
||||||
|
"groups": Counter(),
|
||||||
|
"restarts": 0,
|
||||||
|
"schedule_checks": 0,
|
||||||
|
"schedule_changes": 0,
|
||||||
|
"schedule_failures": 0,
|
||||||
|
"network_errors": 0,
|
||||||
|
"browser_errors": 0,
|
||||||
|
"telegram_errors": Counter(),
|
||||||
|
"performance": {
|
||||||
|
"avg_handling_time": 0,
|
||||||
|
"fastest_handling": float("inf"),
|
||||||
|
"slowest_handling": 0,
|
||||||
|
"handling_count": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Временные метрики
|
||||||
|
start_time = None
|
||||||
|
end_time = None
|
||||||
|
handling_times = []
|
||||||
|
|
||||||
|
# Регулярные выражения для парсинга
|
||||||
|
timestamp_pattern = r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
log_level_pattern = r"\[(INFO|WARNING|ERROR)\]"
|
||||||
|
handling_time_pattern = r"Duration (\d+) ms"
|
||||||
|
command_pattern = r"Команда /rasp от ([\d-]+), группа=([^,]+), дата=(\d+)"
|
||||||
|
schedule_pattern = r"Проверяем расписание для ([^ ]+) на (\d{2}\.\d{2}\.\d{4})"
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Извлекаем временную метку
|
||||||
|
timestamp_match = re.search(timestamp_pattern, line)
|
||||||
|
if timestamp_match:
|
||||||
|
timestamp = timestamp_match.group(1)
|
||||||
|
if not start_time:
|
||||||
|
start_time = timestamp
|
||||||
|
end_time = timestamp
|
||||||
|
|
||||||
|
# Уровень логирования
|
||||||
|
level_match = re.search(log_level_pattern, line)
|
||||||
|
if level_match:
|
||||||
|
level = level_match.group(1)
|
||||||
|
stats["log_levels"][level] += 1
|
||||||
|
|
||||||
|
# Время обработки сообщений
|
||||||
|
time_match = re.search(handling_time_pattern, line)
|
||||||
|
if time_match and "is handled" in line:
|
||||||
|
handling_time = int(time_match.group(1))
|
||||||
|
handling_times.append(handling_time)
|
||||||
|
stats["performance"]["handling_count"] += 1
|
||||||
|
stats["performance"]["slowest_handling"] = max(
|
||||||
|
stats["performance"]["slowest_handling"], handling_time
|
||||||
|
)
|
||||||
|
stats["performance"]["fastest_handling"] = min(
|
||||||
|
stats["performance"]["fastest_handling"], handling_time
|
||||||
|
)
|
||||||
|
|
||||||
|
# Команды пользователей
|
||||||
|
cmd_match = re.search(command_pattern, line)
|
||||||
|
if cmd_match:
|
||||||
|
user_id, group, date_offset = cmd_match.groups()
|
||||||
|
stats["user_commands"][group] += 1
|
||||||
|
stats["groups"][group] += 1
|
||||||
|
|
||||||
|
# Проверки расписания
|
||||||
|
if "Проверяем расписание" in line:
|
||||||
|
stats["schedule_checks"] += 1
|
||||||
|
sched_match = re.search(schedule_pattern, line)
|
||||||
|
if sched_match:
|
||||||
|
group, date = sched_match.groups()
|
||||||
|
stats["groups"][group] += 1
|
||||||
|
|
||||||
|
# Изменения расписания
|
||||||
|
if "Изменения найдены" in line:
|
||||||
|
stats["schedule_changes"] += 1
|
||||||
|
|
||||||
|
# Ошибки расписания
|
||||||
|
if "Не удалось получить расписание" in line:
|
||||||
|
stats["schedule_failures"] += 1
|
||||||
|
|
||||||
|
# Перезапуски бота
|
||||||
|
if "Бот запускается" in line:
|
||||||
|
stats["restarts"] += 1
|
||||||
|
|
||||||
|
# Сетевые ошибки
|
||||||
|
if "Failed to fetch updates" in line:
|
||||||
|
stats["network_errors"] += 1
|
||||||
|
|
||||||
|
# Ошибки браузера
|
||||||
|
if "TargetClosedError" in line or "BrowserContext.close" in line:
|
||||||
|
stats["browser_errors"] += 1
|
||||||
|
|
||||||
|
# Ошибки Telegram API
|
||||||
|
if "Telegram server says" in line:
|
||||||
|
error_msg = line.split("Telegram server says - ")[-1].split(":")[0]
|
||||||
|
stats["telegram_errors"][error_msg] += 1
|
||||||
|
|
||||||
|
# Сбор ошибок и предупреждений
|
||||||
|
if "[ERROR]" in line:
|
||||||
|
error_msg = line.split("[ERROR]")[-1].strip()
|
||||||
|
stats["errors"][error_msg[:100]] += 1
|
||||||
|
|
||||||
|
if "[WARNING]" in line:
|
||||||
|
warning_msg = line.split("[WARNING]")[-1].strip()
|
||||||
|
stats["warnings"][warning_msg[:100]] += 1
|
||||||
|
|
||||||
|
# Расчет средней скорости обработки
|
||||||
|
if handling_times:
|
||||||
|
stats["performance"]["avg_handling_time"] = sum(handling_times) / len(
|
||||||
|
handling_times
|
||||||
|
)
|
||||||
|
|
||||||
|
# Период работы
|
||||||
|
if start_time and end_time:
|
||||||
|
stats["time_period"] = {
|
||||||
|
"start": start_time,
|
||||||
|
"end": end_time,
|
||||||
|
"duration_hours": calculate_duration_hours(start_time, end_time),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Дополнительные метрики
|
||||||
|
stats["success_rate"] = calculate_success_rate(stats)
|
||||||
|
stats["uptime_percentage"] = calculate_uptime_percentage(stats)
|
||||||
|
stats["schedule_success_rate"] = calculate_schedule_success_rate(stats)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_duration_hours(start_str: str, end_str: str) -> float:
|
||||||
|
"""Вычисляет продолжительность в часах"""
|
||||||
|
fmt = "%Y-%m-%d %H:%M:%S"
|
||||||
|
try:
|
||||||
|
start = datetime.strptime(start_str, fmt)
|
||||||
|
end = datetime.strptime(end_str, fmt)
|
||||||
|
return round((end - start).total_seconds() / 3600, 2)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def calculate_success_rate(stats):
|
||||||
|
"""Рассчитывает процент успешных операций"""
|
||||||
|
total_operations = stats["performance"]["handling_count"] + sum(
|
||||||
|
stats["errors"].values()
|
||||||
|
)
|
||||||
|
if total_operations == 0:
|
||||||
|
return 0
|
||||||
|
success_rate = (stats["performance"]["handling_count"] / total_operations) * 100
|
||||||
|
return round(success_rate, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_uptime_percentage(stats):
|
||||||
|
"""Рассчитывает процент времени работы"""
|
||||||
|
if stats["time_period"].get("duration_hours", 0) == 0:
|
||||||
|
return 0
|
||||||
|
# Предполагаем, что каждый перезапуск занимает ~10 секунд
|
||||||
|
restart_downtime = stats["restarts"] * 10 / 3600
|
||||||
|
total_hours = stats["time_period"]["duration_hours"]
|
||||||
|
uptime_hours = total_hours - restart_downtime
|
||||||
|
uptime_percentage = (uptime_hours / total_hours) * 100
|
||||||
|
return round(uptime_percentage, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_schedule_success_rate(stats):
|
||||||
|
"""Рассчитывает процент успешных проверок расписания"""
|
||||||
|
total_checks = stats["schedule_checks"]
|
||||||
|
if total_checks == 0:
|
||||||
|
return 0
|
||||||
|
successful_checks = total_checks - stats["schedule_failures"]
|
||||||
|
success_rate = (successful_checks / total_checks) * 100
|
||||||
|
return round(success_rate, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def create_statistics_text(stats):
|
||||||
|
"""Создает текстовый отчет статистики с расширенными метриками"""
|
||||||
|
if "error" in stats:
|
||||||
|
return f"❌ Ошибка анализа логов: {stats['error']}"
|
||||||
|
|
||||||
|
text = "📊 СТАТИСТИКА РАБОТЫ БОТА\n"
|
||||||
|
text += "=" * 40 + "\n\n"
|
||||||
|
|
||||||
|
# Основная информация
|
||||||
|
text += "📈 ОБЩАЯ ИНФОРМАЦИЯ:\n"
|
||||||
|
text += f"• Период: {stats['time_period'].get('start', 'N/A')} - {stats['time_period'].get('end', 'N/A')}\n"
|
||||||
|
text += f"• Длительность: {stats['time_period'].get('duration_hours', 0)} ч\n"
|
||||||
|
text += f"• Строк в логе: {stats['total_lines']:,}\n\n"
|
||||||
|
|
||||||
|
# Производительность
|
||||||
|
handling_times = stats.get(
|
||||||
|
"handling_times", []
|
||||||
|
) # сохрани список в analyze_bot_logs
|
||||||
|
median_time = statistics.median(handling_times) if handling_times else 0
|
||||||
|
|
||||||
|
text += "⚡ ПРОИЗВОДИТЕЛЬНОСТЬ:\n"
|
||||||
|
text += f"• Среднее время: {stats['performance']['avg_handling_time']:.0f} мс\n"
|
||||||
|
text += f"• Медиана времени: {median_time:.0f} мс\n"
|
||||||
|
text += f"• Сообщений обработано: {stats['performance']['handling_count']}\n"
|
||||||
|
text += f"• Успешных операций: {stats['success_rate']}%\n\n"
|
||||||
|
|
||||||
|
# Статус работы
|
||||||
|
duration = stats["time_period"].get("duration_hours", 0)
|
||||||
|
errors_total = sum(stats["errors"].values())
|
||||||
|
errors_per_hour = round(errors_total / duration, 2) if duration else 0
|
||||||
|
|
||||||
|
restarts = stats["restarts"]
|
||||||
|
mtbf = round(duration / restarts, 2) if restarts else duration
|
||||||
|
|
||||||
|
text += "🔄 СТАТУС РАБОТЫ:\n"
|
||||||
|
text += f"• Перезапусков: {restarts}\n"
|
||||||
|
text += f"• Uptime: {stats['uptime_percentage']}%\n"
|
||||||
|
text += f"• MTBF (среднее время между перезапусками): {mtbf} ч\n"
|
||||||
|
text += f"• Ошибок в час: {errors_per_hour}\n\n"
|
||||||
|
|
||||||
|
# Расписание
|
||||||
|
text += "📅 РАСПИСАНИЕ:\n"
|
||||||
|
text += f"• Проверок: {stats['schedule_checks']}\n"
|
||||||
|
text += f"• Изменений: {stats['schedule_changes']}\n"
|
||||||
|
text += f"• Ошибок: {stats['schedule_failures']}\n"
|
||||||
|
text += f"• Успешных проверок: {stats['schedule_success_rate']}%\n\n"
|
||||||
|
|
||||||
|
# Ошибки
|
||||||
|
text += "🚨 ОШИБКИ:\n"
|
||||||
|
text += f"• Всего ошибок: {errors_total}\n"
|
||||||
|
text += f"• Предупреждений: {sum(stats['warnings'].values())}\n"
|
||||||
|
text += f"• Сетевых: {stats['network_errors']}\n"
|
||||||
|
text += f"• Браузера: {stats['browser_errors']}\n"
|
||||||
|
|
||||||
|
# Топ-3 ошибок
|
||||||
|
if stats["errors"]:
|
||||||
|
text += "• Топ ошибок:\n"
|
||||||
|
for err, count in stats["errors"].most_common(3):
|
||||||
|
text += f" - {err} ({count})\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
# Пользователи
|
||||||
|
text += "👥 АКТИВНОСТЬ:\n"
|
||||||
|
text += f"• Команд: {sum(stats['user_commands'].values())}\n"
|
||||||
|
text += f"• Групп: {len(stats['groups'])}\n"
|
||||||
|
|
||||||
|
if stats["groups"]:
|
||||||
|
text += "• Топ групп:\n"
|
||||||
|
for group, count in stats["groups"].most_common(3):
|
||||||
|
text += f" - {group}: {count}\n"
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def create_statistics_file(stats):
|
||||||
|
"""Создает временный файл с полной статистикой"""
|
||||||
|
if "error" in stats:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Создаем временный файл
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", encoding="utf-8", suffix=".json", delete=False
|
||||||
|
) as f:
|
||||||
|
json.dump(stats, f, ensure_ascii=False, indent=2, default=str)
|
||||||
|
temp_filename = f.name
|
||||||
|
|
||||||
|
return temp_filename
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from functools import wraps
|
||||||
|
from aiogram import types
|
||||||
|
from models.state import BotState
|
||||||
|
from config import Config
|
||||||
|
from storage.message_storage import save_message
|
||||||
|
|
||||||
|
|
||||||
|
def is_chat_spam(chat_id: int, state: BotState) -> bool:
|
||||||
|
"""Проверка на спам"""
|
||||||
|
if chat_id in Config.ADMINS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
if chat_id in state.last_chat_time:
|
||||||
|
delta = (now - state.last_chat_time[chat_id]).total_seconds()
|
||||||
|
if delta < Config.ANTISPAM_DELAY:
|
||||||
|
return True
|
||||||
|
|
||||||
|
state.last_chat_time[chat_id] = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(need_level: int):
|
||||||
|
"""Декоратор для проверки прав администратора (0 = высший уровень)"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(message: types.Message, *args, **kwargs):
|
||||||
|
user_id = message.from_user.id
|
||||||
|
user_level = Config.ADMINS.get(user_id, 9999) # 9999 = "нет прав"
|
||||||
|
|
||||||
|
# чем меньше число, тем выше права
|
||||||
|
if user_level > need_level:
|
||||||
|
await message.answer("⛔ У вас нет доступа к этой команде.")
|
||||||
|
return
|
||||||
|
|
||||||
|
return await func(message, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def saving(func):
|
||||||
|
"""Декоратор для сохранения входящего сообщения"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(message: types.Message, *args, **kwargs):
|
||||||
|
save_message(message.chat.id, message.message_id)
|
||||||
|
return await func(message, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import logging
|
||||||
|
from logging import CRITICAL, NullHandler, getLogger
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Настройка логирования. При DISABLE_LOGGING — полное отключение."""
|
||||||
|
root = getLogger()
|
||||||
|
|
||||||
|
if Config.DISABLE_LOGGING:
|
||||||
|
root.handlers.clear()
|
||||||
|
root.addHandler(NullHandler())
|
||||||
|
root.setLevel(CRITICAL)
|
||||||
|
logging.disable(CRITICAL)
|
||||||
|
return
|
||||||
|
|
||||||
|
from logging import INFO, FileHandler, StreamHandler, basicConfig
|
||||||
|
|
||||||
|
handlers: list[logging.Handler] = [StreamHandler()]
|
||||||
|
|
||||||
|
if not Config.DISABLE_STORAGE:
|
||||||
|
log_path = Config.LOG_FILE
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handlers.append(FileHandler(log_path, encoding="utf-8"))
|
||||||
|
|
||||||
|
basicConfig(
|
||||||
|
level=INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
handlers=handlers,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
async def get_macbook_battery_level():
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"pmset",
|
||||||
|
"-g",
|
||||||
|
"batt",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError(f"Ошибка при выполнении pmset: {stderr.decode().strip()}")
|
||||||
|
|
||||||
|
output = stdout.decode().strip()
|
||||||
|
for part in output.splitlines():
|
||||||
|
if "%" in part:
|
||||||
|
percent_str = part.split("\t")[-1].split(";")[0].strip()
|
||||||
|
return int(percent_str.replace("%", ""))
|
||||||
|
raise ValueError("Не удалось определить уровень заряда")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_process_usage(pid=None):
|
||||||
|
"""
|
||||||
|
Возвращает CPU, MEM% и RSS в мегабайтах для указанного процесса.
|
||||||
|
По умолчанию берёт текущий процесс (os.getpid()).
|
||||||
|
"""
|
||||||
|
if pid is None:
|
||||||
|
pid = os.getpid()
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"ps",
|
||||||
|
"-p",
|
||||||
|
str(pid),
|
||||||
|
"-o",
|
||||||
|
"%cpu,%mem,rss,comm",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError(f"Ошибка ps: {stderr.decode().strip()}")
|
||||||
|
|
||||||
|
lines = stdout.decode().strip().splitlines()
|
||||||
|
if len(lines) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cpu, mem_percent, rss_kb, comm = lines[1].split(None, 3)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pid": pid,
|
||||||
|
"command": comm,
|
||||||
|
"cpu_percent": float(cpu),
|
||||||
|
"mem_percent": float(mem_percent),
|
||||||
|
"rss_mb": int(rss_kb) / 1024, # переводим КБ → МБ
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
battery = await get_macbook_battery_level()
|
||||||
|
usage = await get_process_usage()
|
||||||
|
|
||||||
|
print(f"🔋 Батарея: {battery}%")
|
||||||
|
print(
|
||||||
|
f"🖥 CPU: {usage['cpu_percent']}% | MEM: {usage['mem_percent']}% | RSS: {usage['rss_mb']:.2f} MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user