From 76e5701eba5c5aef390e7f875e4b8208f00d069e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=93=D0=B0=D0=BB=D0=B8=D0=BD=D0=B3=D0=B5=D1=80=20=D0=A0?= =?UTF-8?q?=2E=D0=A1=2E?= Date: Tue, 7 Jul 2026 18:30:17 +0700 Subject: [PATCH] Initial commit: UMB Telegram Bot --- .dockerignore | 12 + .gitignore | 32 +++ AGENTS.md | 49 ++++ Dockerfile | 14 + README.md | 104 +++++++ bot/__init__.py | 0 bot/bot.py | 26 ++ bot/routers/__init__.py | 0 bot/routers/ai.py | 260 +++++++++++++++++ bot/routers/dialogue.py | 170 +++++++++++ bot/routers/layout.py | 29 ++ bot/routers/moderation.py | 27 ++ bot/routers/voice.py | 36 +++ bot/routers/weather.py | 12 + bot/routers/yadisk.py | 103 +++++++ bot/setup_commands.py | 24 ++ bot/utils/__init__.py | 0 bot/utils/ai_client.py | 401 ++++++++++++++++++++++++++ bot/utils/database.py | 514 ++++++++++++++++++++++++++++++++++ bot/utils/layout_converter.py | 17 ++ bot/utils/logging_config.py | 38 +++ bot/utils/memory.py | 133 +++++++++ bot/utils/moderation.py | 41 +++ bot/utils/s3_client.py | 36 +++ bot/utils/voice.py | 112 ++++++++ bot/utils/weather.py | 118 ++++++++ bot/utils/yadisk_download.py | 84 ++++++ config.py | 38 +++ docker-compose.yml | 8 + main.py | 78 ++++++ requirements.txt | 8 + 31 files changed, 2524 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot/__init__.py create mode 100644 bot/bot.py create mode 100644 bot/routers/__init__.py create mode 100644 bot/routers/ai.py create mode 100644 bot/routers/dialogue.py create mode 100644 bot/routers/layout.py create mode 100644 bot/routers/moderation.py create mode 100644 bot/routers/voice.py create mode 100644 bot/routers/weather.py create mode 100644 bot/routers/yadisk.py create mode 100644 bot/setup_commands.py create mode 100644 bot/utils/__init__.py create mode 100644 bot/utils/ai_client.py create mode 100644 bot/utils/database.py create mode 100644 bot/utils/layout_converter.py create mode 100644 bot/utils/logging_config.py create mode 100644 bot/utils/memory.py create mode 100644 bot/utils/moderation.py create mode 100644 bot/utils/s3_client.py create mode 100644 bot/utils/voice.py create mode 100644 bot/utils/weather.py create mode 100644 bot/utils/yadisk_download.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92860e0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +AGENTS.md +README.md +.env +.dockerignore +Dockerfile +docker-compose.yml +bot/data/umb.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b42957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Environment +.env +.venv/ +venv/ + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# Runtime data +bot/data/ +log/ +models/ +backup/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Accidental artifacts +=1.1.0 +=1.34.0 +=1.7.1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..15f7e7f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# UMB Bot - Agent Quickstart + +## Environment & Setup +- `source .venv/bin/activate` before running or testing anything +- `.env` is gitignored; API keys and tokens live there (verified read-only) +- Database: SQLite at `bot/data/umb.db`; initialized automatically on boot via `init_db()` +- **Proxy required**: bot is designed for regions with Telegram blocks (Russia/CIS). All outbound traffic (Telegram API, OpenRouter model fetch, AI requests, Whisper model download) goes through SOCKS5 proxy configured in `.env` via `PROXY_ENABLED=true` and `PROXY_URL`. + +## Exact Command to Run +```bash +python main.py +``` +No separate test/lint/formatter/config commands defined in repo. + +## Architecture Snapshot +- **Entry**: `main.py` → `asyncio.run(main())` +- **Boot sequence**: `init_db()` → proxy setup (if enabled) → `dp.start_polling(bot)` +- **Routers** (included in order in `main.py`): + 1. `moderation.py` — sticker/GIF flood detection (25 in 60s → 5min ban) + 2. `layout.py` — `/start`, `/res` (en→ru layout converter in `layout_converter.py`) + 3. `weather.py` — `/weather ` via OpenWeatherMap + 4. `ai.py` — `/ai`, `/aino`, `/aiyes` (AI assistant "Astra") + 5. `voice.py` — auto-transcribes all voice messages via local faster-whisper (no proxy needed) + +## Voice Recognition +- **Local**: faster-whisper model `base` (~2.5 GB) auto-downloaded on first voice message to `models/faster-whisper/` (portable, next to the bot) +- **Model download**: uses `ALL_PROXY` env var + PySocks to route through SOCKS5 proxy +- **Voice files**: temporarily stored in `bot/data/voice/`, deleted after transcription +- **Flow**: download OGG from Telegram → ffmpeg to WAV → transcribe locally → post raw text to chat +- **Normalization**: capitalize first letter, trailing period, collapse whitespace +- **No proxy** used for actual transcription (fully local on CPU) + +## AI Assistant "Astra" Gotchas +- **Free models**: fetched dynamically from OpenRouter API (`:free` suffix), cached 1 hour. Fallback to hardcoded list if API unavailable. +- **Paid fallback**: `routerai.ru` if all free fail (adds `⚡ Обработано через платный API` suffix) +- **Context**: Last 10 messages per user/chat stored in SQLite (`user_context` table) +- **System prompt**: brief, helpful, reply with "я потеряла мысль"/"смотри, какая птичка!" when context lost +- **Block system**: `/aino @user` blocks 24h (creator only); `/aiyes @user` unblocks +- **Rate-limit behavior**: automatically rotates models on 429 errors + +## DB Tables (source of truth) +- `user_context` — messages for AI context (user_id, chat_id, text, role, timestamp) +- `ai_blocked_users` — AI bans (user_id, chat_id, blocked_by, blocked_at, expires_at) +- `sticker_bans` — sticker flood bans (user_id, chat_id, count, start_time, ban_until, ban_trigger) + +## Run on change +1. Edit source +2. Restart bot (`Ctrl+C` then `python main.py`) +3. DB schema updates via `await init_db()` → `Base.metadata.create_all()` (no migrations) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e4ab46d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +VOLUME /app/bot/data + +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4de6b2 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# UMB — Telegram Bot + +Бот для модерации стикеров/GIF, восстановления раскладки, погоды и AI-ассистента "Астра". + +## Функции + +- **Модерация стикеров и GIF** — если пользователь отправляет 25+ стикеров/GIF за 60 секунд, бот удаляет все последующие стикеры и GIF от этого пользователя в течение 5 минут. Текстовые сообщения не затрагиваются. +- **Восстановление раскладки (`/res`)** — ответьте командой `/res` на сообщение с текстом в неправильной раскладке, и бот переведёт его (например, `gbdj` → `пиво`). +- **Погода (`/weather`)** — покажет подробную погоду в указанном городе через OpenWeatherMap API. +- **AI-ассистент "Астра" (`/ai`)** — перешлите сообщение или ответьте на него командой `/ai`, и Astra проанализирует его с учётом контекста последних 10 сообщений. +- **Управление AI (`/aino`, `/aiyes`)** — владелец чата может заблокировать/разблокировать пользователя от AI. +- **Поддержка SOCKS5 прокси** — настраивается через `.env`. + +## Структура проекта + +``` +umb/ +├── .env # настройки: токен, прокси, API ключи +├── .gitignore +├── requirements.txt +├── main.py # точка входа +├── config.py # конфигурация +└── bot/ + ├── __init__.py + ├── bot.py # инициализация бота и сессии + ├── routers/ + │ ├── __init__.py + │ ├── moderation.py # роутер модерации стикеров/GIF + │ ├── layout.py # роутер /start, /res + │ ├── weather.py # роутер /weather + │ └── ai.py # роутер /ai, /aino, /aiyes + ├── utils/ + │ ├── __init__.py + │ ├── layout_converter.py # конвертер раскладки en→ru + │ ├── weather.py # функция получения погоды + │ ├── ai_client.py # OpenRouter AI клиент + │ ├── database.py # SQLAlchemy модели и функции БД + │ └── moderation.py # логика модерации (устарел, теперь в database.py) + └── data/ + └── umb.db # SQLite база данных +``` + +## Установка + +```bash +cd umb +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Настройка + +Откройте `.env` и укажите необходимые ключи: + +```env +BOT_TOKEN=your_bot_token_here +PROXY_ENABLED=false +PROXY_URL=socks5://user:pass@host:port +API_WEATHER=your_openweather_api_key +OPENROUTER_API_KEY=your_openrouter_api_key +ROUTERAI_API_KEY=your_routerai_api_key +ROUTERAI_BASE_URL=https://routerai.ru/api/v1 +``` + +Для включения прокси установите `PROXY_ENABLED=true` и укажите корректный `PROXY_URL`. + +## Запуск + +```bash +source .venv/bin/activate +python main.py +``` + +## Команды + +| Команда | Описание | +|---------|----------| +| `/start` | Приветствие и информация о боте | +| `/res` | Восстановить раскладку (ответить на сообщение) | +| `/weather <город>` | Погода в указанном городе | +| `/ai` | AI-анализ пересланного/ответного сообщения | +| `/aino @username` | Заблокировать пользователя от AI (только creator, 24ч) | +| `/aiyes @username` | Разблокировать пользователя от AI (только creator) | + +## Настройки модерации + +Изменяются в `config.py`: + +- `MODERATION_LIMIT = 25` — количество стикеров/GIF за окно +- `MODERATION_WINDOW = 60` — окно подсчёта в секундах +- `MODERATION_BAN_DURATION = 300` — длительность бана в секундах + +## AI-ассистент "Астра" + +- **Бесплатные модели** (OpenRouter): пробует по очереди все, при rate-limit переключается на следующую + - `deepseek/deepseek-v4-flash:free` + - `google/gemma-4-26b-a4b-it:free` + - `minimax/minimax-m2.5:free` + - `qwen/qwen3-next-80b-a3b-instruct:free` +- **Платный fallback** (RouterAI): если все бесплатные модели вернули 429, используется `deepseek/deepseek-v4-flash` через `routerai.ru`. В ответе добавляется уведомление `⚡ Обработано через платный API`. +- **Контекст**: хранит последние 10 сообщений каждого пользователя в SQLite +- **Стиль**: отвечает кратко и по делу, если не просят развёрнуто +- **Потеря контекста**: говорит что-то милое ("я потеряла мысль", "смотри, какая птичка!") diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..60c6763 --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,26 @@ +import logging +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.client.session.aiohttp import AiohttpSession +from config import BOT_TOKEN, PROXY_ENABLED, PROXY_URL + +logger = logging.getLogger(__name__) + +session = AiohttpSession() + +if PROXY_ENABLED and PROXY_URL: + session.proxy = PROXY_URL + +bot = Bot(token=BOT_TOKEN, session=session, default=DefaultBotProperties(parse_mode="HTML")) +dp = Dispatcher() + + +async def setup_proxy(): + if not PROXY_ENABLED or not PROXY_URL: + return + try: + me = await bot.me() + logger.info(f"Бот подключен через прокси: @{me.username}") + except Exception as e: + logger.error(f"Не могу соединиться с прокси сервером. Попробуй другой прокси. Ошибка: {e}") + raise diff --git a/bot/routers/__init__.py b/bot/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/routers/ai.py b/bot/routers/ai.py new file mode 100644 index 0000000..dc2ac56 --- /dev/null +++ b/bot/routers/ai.py @@ -0,0 +1,260 @@ +import logging + +from aiogram import Router, F +from aiogram.types import Message +from aiogram.filters import Command + +from bot.utils.ai_client import ask_ai +from bot.utils.database import ( + add_context_message, + get_user_context, + is_ai_blocked, + block_user_from_ai, + unblock_user_from_ai, + get_chat_users, + clear_dialogue, + clear_user_context, + get_or_create_dialogue, + increment_dialogue_count, + save_chat_user, +) +from bot.utils.memory import save_summary_with_embedding, find_relevant_summaries, generate_summary +from config import AI_BLOCK_DEFAULT_DURATION, AI_CONTEXT_LIMIT, AI_DIALOGUE_LIMIT, AI_PHASE2_LIMIT + +logger = logging.getLogger(__name__) + +router = Router() + + +def extract_username(text: str) -> str | None: + parts = text.split() + for part in parts: + if part.startswith("@"): + return part[1:] + if part.startswith("id") and part[2:].isdigit(): + return part[2:] + return None + + +async def get_user_id_by_username(message: Message, username: str) -> int | None: + try: + username_clean = username.lstrip("@") + admins = await message.bot.get_chat_administrators(message.chat.id) + for admin in admins: + if admin.user.username and admin.user.username.lower() == username_clean.lower(): + return admin.user.id + if str(admin.user.id) == username_clean: + return admin.user.id + + if username_clean.isdigit(): + return int(username_clean) + except Exception as e: + logger.error("Error resolving username %s: %s", username, e) + + return None + + +async def _is_creator(message: Message) -> bool: + if message.from_user.id == message.chat.id: + return True + try: + admins = await message.bot.get_chat_administrators(message.chat.id) + for admin in admins: + if admin.status == "creator" and admin.user.id == message.from_user.id: + return True + except Exception as e: + logger.error("Error checking creator: %s", e) + return False + + +async def _not_creator(message: Message) -> bool: + return not await _is_creator(message) + + +@router.message(Command("ai")) +async def cmd_ai(message: Message): + user_id = message.from_user.id + chat_id = message.chat.id + + await save_chat_user(user_id, chat_id, message.from_user.username, message.from_user.full_name or "") + + if await is_ai_blocked(user_id, chat_id): + await message.answer("Тебе временно недоступен AI. Обратись к владельцу чата.", parse_mode=None) + return + + text_parts = message.text.split(maxsplit=1) + has_direct_question = len(text_parts) > 1 and text_parts[1].strip() + + if message.reply_to_message: + target_text = message.reply_to_message.text or message.reply_to_message.caption + if not target_text: + await message.answer("Могу работать только с текстовыми сообщениями.", parse_mode=None) + return + prompt = f"Проанализируй это сообщение:\n\n{target_text}" + context_text = target_text + elif message.forward_from and (message.forward_from.text or message.forward_from.caption): + target_text = message.forward_from.text or message.forward_from.caption + prompt = f"Проанализируй это сообщение:\n\n{target_text}" + context_text = target_text + elif has_direct_question: + prompt = text_parts[1].strip() + context_text = prompt + else: + await _start_dialogue(message) + return + + status_msg = await message.reply("Думаю...", parse_mode=None) + + context = await get_user_context(user_id, chat_id, AI_CONTEXT_LIMIT) + + try: + relevant = await find_relevant_summaries(user_id, chat_id, prompt, top_k=2) + if relevant: + summary_text = "Из прошлых диалогов:\n" + "\n---\n".join(relevant) + context.insert(0, {"role": "system", "content": summary_text}) + except Exception as e: + logger.error("Error fetching relevant summaries: %s", e) + + async def update_status(text: str): + try: + await status_msg.edit_text(text, parse_mode=None) + except Exception: + pass + + response = await ask_ai(prompt, context, status_callback=update_status) + + if not response: + response = "Не удалось получить ответ от AI." + + await add_context_message(user_id, chat_id, context_text, "user") + await add_context_message(user_id, chat_id, response, "assistant") + + try: + await status_msg.edit_text(response, parse_mode="HTML", disable_web_page_preview=True) + except Exception: + await status_msg.edit_text(response, parse_mode=None, disable_web_page_preview=True) + + +async def _start_dialogue(message: Message): + user_id = message.from_user.id + chat_id = message.chat.id + + dialogue = await get_or_create_dialogue(user_id, chat_id) + + if not dialogue["is_active"]: + remaining = int(dialogue["blocked_until"] - __import__("time").time()) if dialogue["blocked_until"] else 0 + mins = remaining // 60 + await message.answer( + f"Твой лимит диалога исчерпан. Попробуй через {mins} мин.", + parse_mode=None, + ) + return + + if dialogue["phase"] == 2 and dialogue["msg_count"] >= AI_PHASE2_LIMIT: + await message.answer("Твой лимит исчерпан.", parse_mode=None) + return + + await message.answer( + "💬 Диалог начат! Отвечай на мои сообщения, чтобы продолжать.\n" + "Отправь /aiclear чтобы завершить.", + parse_mode=None, + ) + + +@router.message(Command("aiuser")) +async def cmd_aiuser(message: Message): + if await _not_creator(message): + return + + try: + users = await get_chat_users(message.chat.id) + + lines = [] + for i, u in enumerate(users, 1): + username_str = f"@{u['username']}" if u.get("username") else "NO DATA" + lines.append(f"{i}. {u['full_name']} | {username_str} | {u['user_id']}") + + text = "\n".join(lines) if lines else "Нет данных о пользователях." + + await message.bot.send_message( + message.from_user.id, + f"📋 Список пользователей чата ({len(users)}):\n\n{text}", + parse_mode=None, + ) + + msg = await message.answer("✅ Список отправлен в ЛС.", parse_mode=None) + except Exception as e: + logger.error("aiuser error: %s", e) + await message.answer("Не удалось получить список пользователей. Возможно, у бота нет доступа.", parse_mode=None) + + +@router.message(Command("aiclear")) +async def cmd_aiclear(message: Message): + user_id = message.from_user.id + chat_id = message.chat.id + + try: + context_messages = await get_user_context(user_id, chat_id, limit=50) + cnt = len(context_messages) if context_messages else 0 + if context_messages and cnt >= 2: + status_msg = await message.answer("Сохраняю выжимку диалога...", parse_mode=None) + summary = await generate_summary(context_messages) + if summary: + await save_summary_with_embedding(user_id, chat_id, summary) + await clear_user_context(user_id, chat_id) + await status_msg.edit_text("✅ Выжимка сохранена. Диалог очищен.", parse_mode=None) + logger.info("aiclear | user=%d chat=%d context=%d summary=%d emb=saved", user_id, chat_id, cnt, len(summary)) + else: + logger.warning("aiclear | user=%d chat=%d context=%d summary=None", user_id, chat_id, cnt) + else: + logger.warning("aiclear | user=%d chat=%d not enough context (%d < 2)", user_id, chat_id, cnt) + except Exception as e: + logger.error("aiclear summary error: %s", e) + + await clear_dialogue(user_id, chat_id) + + from bot.utils.database import unblock_user_from_ai + await unblock_user_from_ai(user_id, chat_id) + + await message.answer("✅ Диалог очищен. Можешь начать новый.", parse_mode=None) + + +@router.message(Command("aino")) +async def cmd_aino(message: Message): + if await _not_creator(message): + return + + username = extract_username(message.text) + if not username: + await message.answer("Укажи пользователя: /aino @username или /aino id", parse_mode=None) + return + + target_user_id = await get_user_id_by_username(message, username) + if not target_user_id: + await message.answer(f"Не удалось найти пользователя {username}.", parse_mode=None) + return + + await block_user_from_ai(target_user_id, message.chat.id, message.from_user.id, AI_BLOCK_DEFAULT_DURATION) + await message.answer(f"Пользователь {username} заблокирован от AI на 24 часа.", parse_mode=None) + + +@router.message(Command("aiyes")) +async def cmd_aiyes(message: Message): + if await _not_creator(message): + return + + username = extract_username(message.text) + if not username: + await message.answer("Укажи пользователя: /aiyes @username или /aiyes id", parse_mode=None) + return + + target_user_id = await get_user_id_by_username(message, username) + if not target_user_id: + await message.answer(f"Не удалось найти пользователя {username}.", parse_mode=None) + return + + unblocked = await unblock_user_from_ai(target_user_id, message.chat.id) + if unblocked: + await message.answer(f"Пользователь {username} разблокирован для AI.", parse_mode=None) + else: + await message.answer(f"Пользователь {username} не был заблокирован от AI.", parse_mode=None) diff --git a/bot/routers/dialogue.py b/bot/routers/dialogue.py new file mode 100644 index 0000000..30b2df8 --- /dev/null +++ b/bot/routers/dialogue.py @@ -0,0 +1,170 @@ +import logging +import time + +from aiogram import Router, F +from aiogram.types import Message + +from bot.utils.ai_client import ask_ai +from bot.utils.database import ( + add_context_message, + get_user_context, + get_or_create_dialogue, + increment_dialogue_count, + reset_dialogue_to_phase2, + block_dialogue, + is_ai_blocked, + save_chat_user, + clear_user_context, +) +from bot.utils.memory import find_relevant_summaries, save_summary_with_embedding, generate_summary +from config import AI_CONTEXT_LIMIT, AI_DIALOGUE_LIMIT, AI_PHASE2_LIMIT, AI_COOLDOWN + +logger = logging.getLogger(__name__) + +router = Router() + + +@router.message(F.text, F.reply_to_message.as_("replied")) +async def handle_dialogue_reply(message: Message, replied: Message): + user_id = message.from_user.id + chat_id = message.chat.id + + await save_chat_user(user_id, chat_id, message.from_user.username, message.from_user.full_name or "") + + if await is_ai_blocked(user_id, chat_id): + return + + replied_from_bot = replied.from_user and replied.from_user.is_bot + + if not replied_from_bot: + return + + dialogue = await get_or_create_dialogue(user_id, chat_id) + + if not dialogue["is_active"]: + return + + count_result = await increment_dialogue_count(user_id, chat_id) + current_count = count_result["msg_count"] + phase = dialogue["phase"] + + logger.info("Dialogue msg | user=%d chat=%d phase=%d count=%d", user_id, chat_id, phase, current_count) + + if phase == 1: + limit = AI_DIALOGUE_LIMIT + else: + limit = AI_PHASE2_LIMIT + + if current_count > limit: + if phase == 1: + logger.info("Dialogue phase1→2 | user=%d chat=%d msg_count=%d", user_id, chat_id, current_count) + await _transition_to_phase2(message, user_id, chat_id) + else: + logger.info("Dialogue ended | user=%d chat=%d msg_count=%d", user_id, chat_id, current_count) + await _end_dialogue(message, user_id, chat_id) + return + + if phase == 1: + context_limit = AI_DIALOGUE_LIMIT + else: + context_limit = AI_PHASE2_LIMIT + + context_messages = await get_user_context(user_id, chat_id, limit=context_limit) + + extra_context = "" + try: + relevant = await find_relevant_summaries(user_id, chat_id, message.text or "", top_k=2) + if relevant: + extra_context = "\n\nИз прошлых диалогов:\n" + "\n---\n".join(relevant[:2]) + except Exception as e: + logger.error("Error fetching relevant summaries: %s", e) + + remaining = limit - current_count + warning = "" + if remaining <= 5: + warning = f"\n\n⚠️ Осталось {remaining} сообщений в этом диалоге." + + system_prompt_extra = "" + if extra_context: + system_prompt_extra += extra_context + if warning: + system_prompt_extra += warning + + message_text = message.text or "" + if system_prompt_extra: + message_text += "\n\n(Контекст)" + system_prompt_extra + + status_msg = await message.answer("✍️", parse_mode=None) + + async def update_status(text: str): + try: + await status_msg.edit_text(text, parse_mode=None) + except Exception: + pass + + response = await ask_ai( + message_text, + context_messages, + status_callback=update_status, + ) + + if not response: + response = "Не могу ответить сейчас." + + await add_context_message(user_id, chat_id, message.text or "", "user") + await add_context_message(user_id, chat_id, response, "assistant") + + try: + await message.reply(response, parse_mode="HTML", disable_web_page_preview=True) + except Exception: + await message.reply(response, parse_mode=None, disable_web_page_preview=True) + + try: + await status_msg.delete() + except Exception: + pass + + if remaining <= 3 and remaining > 0: + try: + warn_msg = await message.reply( + f"⚠️ Осталось {remaining} сообщений. Память почти заполнена.", + parse_mode=None, + ) + except Exception: + pass + + +async def _transition_to_phase2(message: Message, user_id: int, chat_id: int): + status_msg = await message.answer("Сохраняю выжимку диалога...", parse_mode=None) + + try: + context = await get_user_context(user_id, chat_id, limit=AI_DIALOGUE_LIMIT) + if context and len(context) >= 4: + summary = await generate_summary(context) + if summary: + await save_summary_with_embedding(user_id, chat_id, summary) + await clear_user_context(user_id, chat_id) + except Exception as e: + logger.error("Error saving summary: %s", e) + + await reset_dialogue_to_phase2(user_id, chat_id) + + try: + await status_msg.edit_text("✅ Начинаю новую сессию (осталось 20 сообщений).", parse_mode=None) + except Exception: + pass + + +async def _end_dialogue(message: Message, user_id: int, chat_id: int): + context = await get_user_context(user_id, chat_id, limit=AI_PHASE2_LIMIT) + if context and len(context) >= 4: + try: + summary = await generate_summary(context) + if summary: + await save_summary_with_embedding(user_id, chat_id, summary) + except Exception as e: + logger.error("Error saving final summary: %s", e) + + await block_dialogue(user_id, chat_id, AI_COOLDOWN) + + await message.reply("Твой лимит исчерпан. Возвращайся через час.", parse_mode=None) diff --git a/bot/routers/layout.py b/bot/routers/layout.py new file mode 100644 index 0000000..ddf521b --- /dev/null +++ b/bot/routers/layout.py @@ -0,0 +1,29 @@ +from aiogram import Router, F +from aiogram.types import Message +from aiogram.filters import Command + +from bot.utils.layout_converter import convert_layout + +router = Router() + + +@router.message(Command("start")) +async def cmd_start(message: Message): + await message.answer( + f"Привет, {message.from_user.full_name}!\n" + "Я бот для модерации стикеров/GIF и восстановления раскладки.\n" + "Используй /res, ответив на сообщение с неправильной раскладкой.", + parse_mode=None, + ) + + +@router.message(Command("res")) +async def cmd_res(message: Message): + if message.reply_to_message and message.reply_to_message.text: + restored_text = convert_layout(message.reply_to_message.text) + await message.answer(restored_text, parse_mode=None) + else: + await message.answer( + "Пожалуйста, ответьте командой /res на сообщение с текстом.", + parse_mode=None, + ) diff --git a/bot/routers/moderation.py b/bot/routers/moderation.py new file mode 100644 index 0000000..627feb1 --- /dev/null +++ b/bot/routers/moderation.py @@ -0,0 +1,27 @@ +from aiogram import Router, F +from aiogram.types import Message + +from bot.utils.database import add_sticker_message, ban_user_stickers, is_user_sticker_banned +from config import MODERATION_LIMIT + +router = Router() + + +@router.message(F.sticker | F.animation) +async def handle_sticker_or_gif(message: Message): + user_id = message.from_user.id + chat_id = message.chat.id + + if await is_user_sticker_banned(user_id, chat_id): + await message.delete() + return + + count = await add_sticker_message(user_id, chat_id) + + if count >= MODERATION_LIMIT: + await ban_user_stickers(user_id, chat_id) + await message.answer( + f"⚠️ {message.from_user.full_name}, вы превысили лимит стикеров/GIF!\n" + "Отправка стикеров и GIF ограничена на 5 минут.", + parse_mode=None, + ) diff --git a/bot/routers/voice.py b/bot/routers/voice.py new file mode 100644 index 0000000..173f1bb --- /dev/null +++ b/bot/routers/voice.py @@ -0,0 +1,36 @@ +import logging + +from aiogram import Router, F +from aiogram.types import Message + +from bot.utils.voice import download_voice, convert_to_wav, transcribe_audio, normalize_text, cleanup_files + +logger = logging.getLogger(__name__) + +router = Router() + + +@router.message(F.voice) +async def handle_voice(message: Message): + status_msg = await message.answer("🎤 Распознаю речь...") + + ogg_path = None + wav_path = None + + try: + ogg_path = await download_voice(message.bot, message.voice.file_id) + wav_path = await convert_to_wav(ogg_path) + text = await transcribe_audio(wav_path) + + if not text: + await status_msg.edit_text("❌ Не удалось распознать речь.") + return + + text = normalize_text(text) + await status_msg.edit_text(f"📝 {text}") + + except Exception as e: + logger.exception("Voice processing error") + await status_msg.edit_text("❌ Ошибка при обработке голосового сообщения.") + finally: + await cleanup_files(ogg_path, wav_path) diff --git a/bot/routers/weather.py b/bot/routers/weather.py new file mode 100644 index 0000000..a4e1e11 --- /dev/null +++ b/bot/routers/weather.py @@ -0,0 +1,12 @@ +from aiogram import Router +from aiogram.types import Message +from aiogram.filters import Command + +from bot.utils.weather import get_weather + +router = Router() + + +@router.message(Command("weather")) +async def cmd_weather(message: Message): + await get_weather(message) diff --git a/bot/routers/yadisk.py b/bot/routers/yadisk.py new file mode 100644 index 0000000..7eb407c --- /dev/null +++ b/bot/routers/yadisk.py @@ -0,0 +1,103 @@ +import asyncio +import logging +import os + +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message, FSInputFile + +from bot.utils.database import get_file_id, save_file_id +from bot.utils.yadisk_download import download_yandex_file +from bot.utils.s3_client import upload_file + +router = Router() +logger = logging.getLogger("yadisk") + +MAX_TELEGRAM_FILE_SIZE = 50 * 1024 * 1024 + + +def is_valid_yandex_public_link(value: str) -> bool: + value = value.strip().lower() + return ( + value.startswith("https://disk.yandex.") + or value.startswith("http://disk.yandex.") + or value.startswith("https://yadi.sk/") + or value.startswith("http://yadi.sk/") + ) + + +@router.message(Command("ydf")) +async def yandex_download_handler(message: Message): + text = (message.text or "").strip() + parts = text.split(maxsplit=1) + + if len(parts) < 2 or not parts[1].strip(): + await message.answer("Укажите ссылку: /ydf https://yadi.sk/...") + return + + url = parts[1].strip() + + if url.startswith("(new)"): + url = url.replace("(new)", "", 1).strip() + if not url: + await message.answer("После (new) укажите ссылку.") + return + await save_file_id(url, None) + + if not is_valid_yandex_public_link(url): + await message.answer("Это не похоже на публичную ссылку Яндекс.Диска.") + return + + cached_id = await get_file_id(url) + if cached_id: + await message.answer_document( + cached_id, + caption="Файл из кеша Telegram. /ydf (new) [ссылка] для обновления.", + ) + return + + status_msg = await message.answer("Начинаю загрузку с Яндекс.Диска...") + last_text = "Начинаю загрузку с Яндекс.Диска..." + + async def progress(downloaded: int, total: int): + nonlocal last_text + if total <= 0: + return + pct = int(downloaded / total * 100) + new_text = f"Загрузка: {pct}%" + if new_text != last_text and pct % 5 == 0: + try: + await status_msg.edit_text(new_text) + last_text = new_text + except Exception: + pass + + try: + file_path = await download_yandex_file(url, progress_callback=progress) + except Exception as exc: + logger.exception("Ошибка скачивания с Яндекс.Диска") + await status_msg.edit_text("Не удалось скачать файл. Проверьте ссылку.") + return + + try: + file_size = os.path.getsize(file_path) + + if file_size < MAX_TELEGRAM_FILE_SIZE: + doc = FSInputFile(file_path) + sent = await message.answer_document(doc) + if sent.document and sent.document.file_id: + await save_file_id(url, sent.document.file_id) + await status_msg.delete() + else: + await status_msg.edit_text("Файл больше 50MB, загружаю в облако...") + s3_url = await asyncio.to_thread(upload_file, file_path) + if s3_url: + await message.answer(f"Файл доступен для скачивания: {s3_url}") + await status_msg.delete() + else: + await status_msg.edit_text("Не удалось загрузить файл в облако.") + finally: + try: + os.remove(file_path) + except OSError: + pass diff --git a/bot/setup_commands.py b/bot/setup_commands.py new file mode 100644 index 0000000..572f9ca --- /dev/null +++ b/bot/setup_commands.py @@ -0,0 +1,24 @@ +import logging + +from aiogram.types import BotCommand +from bot.bot import bot + +logger = logging.getLogger("setup_commands") + +COMMANDS = [ + BotCommand(command="start", description="Запуск бота"), + BotCommand(command="res", description="Исправить раскладку"), + BotCommand(command="weather", description="Погода в городе"), + BotCommand(command="ai", description="Спросить Астру"), + BotCommand(command="aino", description="Заблокировать (админ)"), + BotCommand(command="aiyes", description="Разблокировать (админ)"), + BotCommand(command="ydf", description="Скачать с Яндекс.Диска"), +] + + +async def setup_bot_commands(): + try: + await bot.set_my_commands(COMMANDS) + logger.info("Меню команд установлено: %d команд", len(COMMANDS)) + except Exception as exc: + logger.error("Не удалось установить меню команд: %s", exc) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/utils/ai_client.py b/bot/utils/ai_client.py new file mode 100644 index 0000000..fdcdc78 --- /dev/null +++ b/bot/utils/ai_client.py @@ -0,0 +1,401 @@ +import asyncio +import logging +import re +import time + +import aiohttp +from aiohttp_socks import ProxyConnector, ProxyType + +from config import ( + OPENROUTER_API_KEY, + AI_SYSTEM_PROMPT, + PROXY_ENABLED, + PROXY_URL, + ROUTERAI_API_KEY, + ROUTERAI_BASE_URL, + ROUTERAI_MODEL, +) + +logger = logging.getLogger(__name__) + +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +ROUTERAI_URL = f"{ROUTERAI_BASE_URL}/chat/completions" + +PAID_NOTICE = "\n\n⚡ Обработано через платный API" + +_request_timeout = aiohttp.ClientTimeout(total=60, sock_connect=15, sock_read=30) + + +def get_client_timeout(total: int = 60) -> aiohttp.ClientTimeout: + return aiohttp.ClientTimeout(total=total, sock_connect=15, sock_read=30) + + +_free_models_cache = [] +_free_models_cache_time = 0 +_free_models_cache_ttl = 3600 + +_working_models_cache = [] +_working_models_cache_time = 0 +_working_models_cache_ttl = 600 + + +def _get_connector(): + if PROXY_ENABLED and PROXY_URL: + parsed = PROXY_URL.replace("socks5://", "").replace("socks5h://", "") + if "@" in parsed: + auth, host_port = parsed.split("@", 1) + username, password = auth.split(":", 1) + else: + username = None + password = None + host_port = parsed + + host, port = host_port.rsplit(":", 1) + port = int(port) + + return ProxyConnector( + proxy_type=ProxyType.SOCKS5, + host=host, + port=port, + username=username, + password=password, + ) + return None + + +def _md_to_html(text: str) -> str: + text = re.sub(r"```(\w*)\n(.*?)```", r"
\2
", text, flags=re.DOTALL) + text = re.sub(r"`(.*?)`", r"\1", text) + text = re.sub(r"\*\*(.*?)\*\*", r"\1", text) + text = re.sub(r"\*(.*?)\*", r"\1", text) + text = re.sub(r"__(.*?)__", r"\1", text) + text = re.sub(r"~~(.*?)~~", r"\1", text) + text = re.sub(r"\[(.*?)\]\((.*?)\)", r'\1', text) + return text + + +async def _fetch_free_models() -> list[str]: + global _free_models_cache, _free_models_cache_time + + now = time.time() + if _free_models_cache and (now - _free_models_cache_time) < _free_models_cache_ttl: + return _free_models_cache + + logger.info("Fetching free models from OpenRouter API...") + + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + } + + try: + connector = _get_connector() + async with aiohttp.ClientSession(connector=connector) as session: + async with session.get("https://openrouter.ai/api/v1/models", headers=headers) as response: + if response.status == 200: + data = await response.json() + models = data.get("data", []) + + free_models = [] + for model in models: + model_id = model.get("id", "") + if model_id.endswith(":free"): + free_models.append(model_id) + + if free_models: + _free_models_cache = free_models + _free_models_cache_time = now + logger.info(f"Fetched {len(free_models)} free models") + return free_models + except Exception as e: + logger.warning(f"Failed to fetch free models: {e}") + + if _free_models_cache: + return _free_models_cache + + fallback = [ + "deepseek/deepseek-v4-flash:free", + "google/gemma-4-26b-a4b-it:free", + "minimax/minimax-m2.5:free", + "qwen/qwen3-next-80b-a3b-instruct:free", + ] + return fallback + + +async def _test_model(session, model: str) -> bool: + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + } + payload = { + "model": model, + "messages": [{"role": "user", "content": "reply OK"}], + "max_tokens": 5, + } + try: + async with session.post(OPENROUTER_URL, json=payload, headers=headers) as response: + if response.status == 200: + data = await response.json() + choices = data.get("choices", []) + return bool(choices and choices[0]["message"].get("content")) + except Exception: + pass + return False + + +async def _update_working_models(): + global _working_models_cache, _working_models_cache_time + + free_models = await _fetch_free_models() + if not free_models: + return + + connector = _get_connector() + working = [] + + async with aiohttp.ClientSession(connector=connector) as session: + for model in free_models: + if await _test_model(session, model): + working.append(model) + await asyncio.sleep(0.3) + + _working_models_cache = working + _working_models_cache_time = time.time() + logger.info(f"Health check: {len(working)}/{len(free_models)} models working") + + +async def start_model_health_check(): + await asyncio.sleep(30) + try: + await _update_working_models() + except Exception as e: + logger.error(f"Initial health check error: {e}") + + while True: + await asyncio.sleep(600) + try: + await _update_working_models() + except Exception as e: + logger.error(f"Health check error: {e}") + + +def _log_usage(source: str, model: str, data: dict, latency: float): + usage = data.get("usage") + if usage: + logger.info( + "AI %s | model=%s in_tok=%s out_tok=%s total_tok=%s latency=%.1fs", + source, model, + usage.get("prompt_tokens", "?"), + usage.get("completion_tokens", "?"), + usage.get("total_tokens", "?"), + latency, + ) + else: + logger.info( + "AI %s | model=%s latency=%.1fs", + source, model, latency, + ) + + +async def _try_openrouter(session, model: str, messages: list[dict], headers: dict, payload: dict) -> str | None: + payload["model"] = model + start = time.monotonic() + + async with session.post(OPENROUTER_URL, json=payload, headers=headers) as response: + latency = time.monotonic() - start + + if response.status != 200: + error_body = await response.text() + logger.warning("OpenRouter error | model=%s status=%s latency=%.1fs error=%s", model, response.status, latency, error_body[:200]) + return None + + data = await response.json() + choices = data.get("choices", []) + if not choices: + logger.warning("OpenRouter empty choices | model=%s latency=%.1fs", model, latency) + return None + + content = choices[0]["message"].get("content") + if not content: + logger.warning("OpenRouter empty content | model=%s latency=%.1fs", model, latency) + return None + + _log_usage("OpenRouter", model, data, latency) + return _md_to_html(content) + + +async def _try_routerai(session, messages: list[dict]) -> str | None: + if not ROUTERAI_API_KEY: + logger.warning("RouterAI skipped | key not set") + return None + + headers = { + "Authorization": f"Bearer {ROUTERAI_API_KEY}", + "Content-Type": "application/json", + } + + payload = { + "model": ROUTERAI_MODEL, + "messages": messages, + "max_tokens": 512, + } + + start = time.monotonic() + + async with session.post(ROUTERAI_URL, json=payload, headers=headers) as response: + latency = time.monotonic() - start + + if response.status != 200: + error_body = await response.text() + logger.warning("RouterAI error | status=%s latency=%.1fs error=%s", response.status, latency, error_body[:200]) + return None + + data = await response.json() + choices = data.get("choices", []) + if not choices: + logger.warning("RouterAI empty choices | latency=%.1fs", latency) + return None + + content = choices[0]["message"].get("content") + if not content: + logger.warning("RouterAI empty content | latency=%.1fs", latency) + return None + + _log_usage("RouterAI", ROUTERAI_MODEL, data, latency) + return _md_to_html(content) + PAID_NOTICE + + +async def ask_ai_simple(prompt: str) -> str | None: + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + } + + payload = { + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 512, + } + + connector = _get_connector() + + try: + async with aiohttp.ClientSession(timeout=_request_timeout, connector=connector) as session: + free_models = await _fetch_free_models() + for model in free_models[:5]: + payload["model"] = model + start = time.monotonic() + async with session.post(OPENROUTER_URL, json=payload, headers=headers) as response: + latency = time.monotonic() - start + if response.status == 200: + data = await response.json() + choices = data.get("choices", []) + if choices and choices[0]["message"].get("content"): + _log_usage("ask_ai_simple", model, data, latency) + return choices[0]["message"]["content"] + else: + logger.warning("ask_ai_simple fallback fail | model=%s status=%s latency=%.1fs", model, response.status, latency) + + logger.warning("ask_ai_simple | all free models failed, trying RouterAI") + routerai_payload = { + "model": ROUTERAI_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 512, + } + routerai_headers = { + "Authorization": f"Bearer {ROUTERAI_API_KEY}", + "Content-Type": "application/json", + } + start = time.monotonic() + async with session.post(ROUTERAI_URL, json=routerai_payload, headers=routerai_headers) as response: + latency = time.monotonic() - start + if response.status == 200: + data = await response.json() + choices = data.get("choices", []) + if choices and choices[0]["message"].get("content"): + _log_usage("ask_ai_simple (RouterAI)", ROUTERAI_MODEL, data, latency) + return choices[0]["message"]["content"] + except Exception as e: + logger.error("ask_ai_simple error: %s", e) + + return None + + +async def ask_ai(prompt: str, context_messages: list[dict] | None = None, status_callback=None) -> str: + messages = [{"role": "system", "content": AI_SYSTEM_PROMPT}] + + if context_messages: + messages.extend(context_messages) + + messages.append({"role": "user", "content": prompt}) + + or_headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/umb-bot", + "X-Title": "UMB Bot", + } + + payload = { + "messages": messages, + "max_tokens": 512, + } + + timeout = aiohttp.ClientTimeout(total=60, sock_connect=15, sock_read=30) + connector = _get_connector() + + waiting_messages = [ + "Думаю...", + "Ой, надо ещё подумать...", + "Секундочку...", + "Ищу ответ...", + "Думаю...", + "Почти готово...", + "Переключаюсь на платный API...", + ] + + try: + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + free_models = await _fetch_free_models() + + if _working_models_cache: + models_to_try = [m for m in _working_models_cache if m in free_models] + if not models_to_try: + models_to_try = free_models + else: + models_to_try = free_models + + logger.info("ask_ai | trying %d models", len(models_to_try)) + + for i, model in enumerate(models_to_try): + if status_callback and i > 0: + wait_idx = min(i, len(waiting_messages) - 1) + await status_callback(waiting_messages[wait_idx]) + + result = await _try_openrouter(session, model, messages, or_headers, payload) + if result: + return result + + logger.info("ask_ai | model %s failed, trying next", model) + + logger.warning("ask_ai | switching to RouterAI") + + if status_callback: + await status_callback(waiting_messages[-1]) + + paid_result = await _try_routerai(session, messages) + if paid_result: + return paid_result + + logger.warning("ask_ai | RouterAI failed too") + + logger.error("ask_ai | all models failed") + return "Извини, ни одна модель не смогла ответить. Попробуй позже." + except aiohttp.ClientError as e: + logger.error("AI request network error: %s", e) + return "Не удалось связаться с AI сервисом. Проверь соединение." + except asyncio.TimeoutError: + logger.error("ask_ai | timeout after all models exhausted") + return "Сервер AI не ответил вовремя. Попробуй позже." + except Exception: + logger.exception("ask_ai | unexpected error") + return "Произошла ошибка при обработке запроса." diff --git a/bot/utils/database.py b/bot/utils/database.py new file mode 100644 index 0000000..1811ae3 --- /dev/null +++ b/bot/utils/database.py @@ -0,0 +1,514 @@ +import time +from datetime import datetime + +from sqlalchemy import Column, Integer, String, Float, BigInteger, Text +from sqlalchemy.orm import declarative_base +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +from config import DATABASE_URL + +Base = declarative_base() + + +class UserContext(Base): + __tablename__ = "user_context" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False, index=True) + chat_id = Column(BigInteger, nullable=False, index=True) + text = Column(Text, nullable=False) + role = Column(String(16), nullable=False) + timestamp = Column(Float, nullable=False) + + +class AiBlockedUser(Base): + __tablename__ = "ai_blocked_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False, index=True) + chat_id = Column(BigInteger, nullable=False, index=True) + blocked_by = Column(BigInteger, nullable=False) + blocked_at = Column(Float, nullable=False) + expires_at = Column(Float, nullable=False) + + +class FileIdCache(Base): + __tablename__ = "file_ids" + + file_key = Column(String, primary_key=True) + file_id = Column(String, nullable=False) + + +class StickerBan(Base): + __tablename__ = "sticker_bans" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False, index=True) + chat_id = Column(BigInteger, nullable=False, index=True) + count = Column(Integer, default=0) + start_time = Column(Float, nullable=False) + ban_until = Column(Float, nullable=True) + ban_trigger = Column(Integer, default=0) + + +class ChatUser(Base): + __tablename__ = "chat_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + chat_id = Column(BigInteger, nullable=False, index=True) + user_id = Column(BigInteger, nullable=False, index=True) + username = Column(String, nullable=True) + full_name = Column(String, nullable=False) + last_seen = Column(Float, nullable=False) + + +class DialogueSession(Base): + __tablename__ = "dialogue_sessions" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False, index=True) + chat_id = Column(BigInteger, nullable=False, index=True) + phase = Column(Integer, default=1) + msg_count = Column(Integer, default=0) + is_active = Column(Integer, default=0) + blocked_until = Column(Float, nullable=True) + last_activity = Column(Float, nullable=False) + + +class ConversationSummary(Base): + __tablename__ = "conversation_summaries" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False, index=True) + chat_id = Column(BigInteger, nullable=False, index=True) + summary = Column(Text, nullable=False) + embedding = Column(Text, nullable=True) + created_at = Column(Float, nullable=False) + + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def add_context_message(user_id: int, chat_id: int, text: str, role: str) -> None: + if text is None: + text = "" + async with async_session() as session: + msg = UserContext( + user_id=user_id, + chat_id=chat_id, + text=text, + role=role, + timestamp=time.time(), + ) + session.add(msg) + await session.commit() + + +async def get_user_context(user_id: int, chat_id: int, limit: int = 10) -> list[dict]: + async with async_session() as session: + from sqlalchemy import select + + stmt = ( + select(UserContext) + .where(UserContext.user_id == user_id, UserContext.chat_id == chat_id) + .order_by(UserContext.timestamp.desc()) + .limit(limit) + ) + result = await session.execute(stmt) + messages = result.scalars().all() + return [{"role": m.role, "content": m.text} for m in reversed(messages)] + + +async def is_ai_blocked(user_id: int, chat_id: int) -> bool: + async with async_session() as session: + from sqlalchemy import select + + stmt = ( + select(AiBlockedUser) + .where(AiBlockedUser.user_id == user_id, AiBlockedUser.chat_id == chat_id) + .where(AiBlockedUser.expires_at > time.time()) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() is not None + + +async def block_user_from_ai(user_id: int, chat_id: int, blocked_by: int, duration: int = 86400) -> None: + async with async_session() as session: + from sqlalchemy import select, delete + + stmt = delete(AiBlockedUser).where( + AiBlockedUser.user_id == user_id, + AiBlockedUser.chat_id == chat_id, + ) + await session.execute(stmt) + + now = time.time() + entry = AiBlockedUser( + user_id=user_id, + chat_id=chat_id, + blocked_by=blocked_by, + blocked_at=now, + expires_at=now + duration, + ) + session.add(entry) + await session.commit() + + +async def unblock_user_from_ai(user_id: int, chat_id: int) -> bool: + async with async_session() as session: + from sqlalchemy import select, delete + + stmt = delete(AiBlockedUser).where( + AiBlockedUser.user_id == user_id, + AiBlockedUser.chat_id == chat_id, + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount > 0 + + +async def get_sticker_ban(user_id: int, chat_id: int) -> dict | None: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(StickerBan).where( + StickerBan.user_id == user_id, + StickerBan.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + if not row: + return None + return { + "count": row.count, + "start_time": row.start_time, + "ban_until": row.ban_until, + "ban_trigger": row.ban_trigger, + } + + +async def add_sticker_message(user_id: int, chat_id: int) -> int: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(StickerBan).where( + StickerBan.user_id == user_id, + StickerBan.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + now = time.time() + + if not row: + row = StickerBan( + user_id=user_id, + chat_id=chat_id, + count=1, + start_time=now, + ) + session.add(row) + else: + if now - row.start_time > 60: + row.count = 1 + row.start_time = now + row.ban_until = None + row.ban_trigger = 0 + else: + row.count += 1 + + await session.commit() + return row.count + + +async def ban_user_stickers(user_id: int, chat_id: int, duration: int = 300) -> None: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(StickerBan).where( + StickerBan.user_id == user_id, + StickerBan.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if row: + row.ban_until = time.time() + duration + row.ban_trigger = row.count + await session.commit() + + +async def is_user_sticker_banned(user_id: int, chat_id: int) -> bool: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(StickerBan).where( + StickerBan.user_id == user_id, + StickerBan.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if not row or not row.ban_until: + return False + + if time.time() > row.ban_until: + row.ban_until = None + row.count = 0 + row.ban_trigger = 0 + await session.commit() + return False + + return True + + +async def get_file_id(file_key: str) -> str | None: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(FileIdCache).where(FileIdCache.file_key == file_key) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + return row.file_id if row else None + + +async def save_file_id(file_key: str, file_id: str | None) -> None: + async with async_session() as session: + from sqlalchemy import delete + + await session.execute(delete(FileIdCache).where(FileIdCache.file_key == file_key)) + if file_id is not None: + session.add(FileIdCache(file_key=file_key, file_id=file_id)) + await session.commit() + + +async def save_chat_user(user_id: int, chat_id: int, username: str | None, full_name: str) -> None: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(ChatUser).where( + ChatUser.user_id == user_id, + ChatUser.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + now = time.time() + + if row: + row.username = username + row.full_name = full_name + row.last_seen = now + else: + session.add(ChatUser( + user_id=user_id, + chat_id=chat_id, + username=username, + full_name=full_name, + last_seen=now, + )) + await session.commit() + + +async def get_chat_users(chat_id: int) -> list[dict]: + async with async_session() as session: + from sqlalchemy import select + + stmt = ( + select(ChatUser) + .where(ChatUser.chat_id == chat_id) + .order_by(ChatUser.full_name) + ) + result = await session.execute(stmt) + return [ + {"user_id": u.user_id, "username": u.username, "full_name": u.full_name} + for u in result.scalars().all() + ] + + +async def get_or_create_dialogue(user_id: int, chat_id: int) -> dict: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(DialogueSession).where( + DialogueSession.user_id == user_id, + DialogueSession.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + now = time.time() + + if not row: + row = DialogueSession( + user_id=user_id, + chat_id=chat_id, + phase=1, + msg_count=0, + is_active=1, + last_activity=now, + ) + session.add(row) + await session.commit() + return {"phase": 1, "msg_count": 0, "is_active": True, "blocked_until": None} + + if row.blocked_until and now < row.blocked_until: + return { + "phase": row.phase, + "msg_count": row.msg_count, + "is_active": False, + "blocked_until": row.blocked_until, + } + + row.is_active = 1 + row.last_activity = now + await session.commit() + + return { + "phase": row.phase, + "msg_count": row.msg_count, + "is_active": True, + "blocked_until": None, + } + + +async def increment_dialogue_count(user_id: int, chat_id: int) -> dict: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(DialogueSession).where( + DialogueSession.user_id == user_id, + DialogueSession.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if not row: + return {"phase": 1, "msg_count": 0, "limit_reached": False} + + row.msg_count += 1 + row.last_activity = time.time() + await session.commit() + + return {"phase": row.phase, "msg_count": row.msg_count, "limit_reached": False} + + +async def reset_dialogue_to_phase2(user_id: int, chat_id: int) -> None: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(DialogueSession).where( + DialogueSession.user_id == user_id, + DialogueSession.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if row: + row.phase = 2 + row.msg_count = 0 + row.last_activity = time.time() + await session.commit() + + +async def block_dialogue(user_id: int, chat_id: int, duration: int = 3600) -> None: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(DialogueSession).where( + DialogueSession.user_id == user_id, + DialogueSession.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if row: + row.blocked_until = time.time() + duration + row.is_active = 0 + await session.commit() + + +async def clear_dialogue(user_id: int, chat_id: int) -> None: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(DialogueSession).where( + DialogueSession.user_id == user_id, + DialogueSession.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if row: + row.phase = 1 + row.msg_count = 0 + row.is_active = 0 + row.last_activity = time.time() + await session.commit() + + +async def clear_user_context(user_id: int, chat_id: int) -> None: + async with async_session() as session: + from sqlalchemy import delete + + stmt = delete(UserContext).where( + UserContext.user_id == user_id, + UserContext.chat_id == chat_id, + ) + await session.execute(stmt) + await session.commit() + + +async def save_conversation_summary(user_id: int, chat_id: int, summary: str, embedding: str | None = None) -> None: + async with async_session() as session: + session.add(ConversationSummary( + user_id=user_id, + chat_id=chat_id, + summary=summary, + embedding=embedding, + created_at=time.time(), + )) + await session.commit() + + +async def get_summaries(user_id: int, chat_id: int, limit: int = 5) -> list[dict]: + async with async_session() as session: + from sqlalchemy import select + + stmt = ( + select(ConversationSummary) + .where(ConversationSummary.user_id == user_id, ConversationSummary.chat_id == chat_id) + .order_by(ConversationSummary.created_at.desc()) + .limit(limit) + ) + result = await session.execute(stmt) + return [ + {"id": s.id, "summary": s.summary, "embedding": s.embedding, "created_at": s.created_at} + for s in result.scalars().all() + ] + + +async def unban_user_stickers(user_id: int, chat_id: int) -> bool: + async with async_session() as session: + from sqlalchemy import select + + stmt = select(StickerBan).where( + StickerBan.user_id == user_id, + StickerBan.chat_id == chat_id, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if row: + row.ban_until = None + row.count = 0 + row.ban_trigger = 0 + await session.commit() + return True + return False diff --git a/bot/utils/layout_converter.py b/bot/utils/layout_converter.py new file mode 100644 index 0000000..fc41231 --- /dev/null +++ b/bot/utils/layout_converter.py @@ -0,0 +1,17 @@ +def convert_layout(text: str) -> str: + english_chars = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`" + russian_chars = "йцукенгшщзхъфывапролджэячсмитьбюё" + + converted_text = "" + + for char in text: + if char.lower() in english_chars: + char_index = english_chars.index(char.lower()) + converted_char = russian_chars[char_index] + if char.isupper(): + converted_char = converted_char.upper() + converted_text += converted_char + else: + converted_text += char + + return converted_text diff --git a/bot/utils/logging_config.py b/bot/utils/logging_config.py new file mode 100644 index 0000000..7f070c8 --- /dev/null +++ b/bot/utils/logging_config.py @@ -0,0 +1,38 @@ +import logging +import os +import sys +from logging.handlers import RotatingFileHandler + +LOG_DIR = "log" +LOG_FILE = os.path.join(LOG_DIR, "umb.log") +LOG_MAX_BYTES = 10 * 1024 * 1024 +LOG_BACKUP_COUNT = 5 +LOG_FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(funcName)s - %(message)s" + + +def setup_logging(): + os.makedirs(LOG_DIR, exist_ok=True) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + file_handler = RotatingFileHandler( + LOG_FILE, + maxBytes=LOG_MAX_BYTES, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8", + ) + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter(LOG_FORMAT)) + root_logger.addHandler(file_handler) + + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setLevel(logging.INFO) + stream_handler.setFormatter(logging.Formatter(LOG_FORMAT)) + root_logger.addHandler(stream_handler) + + logging.getLogger("aiogram").setLevel(logging.INFO) + logging.getLogger("aiogram.dispatcher").setLevel(logging.INFO) diff --git a/bot/utils/memory.py b/bot/utils/memory.py new file mode 100644 index 0000000..4fbe007 --- /dev/null +++ b/bot/utils/memory.py @@ -0,0 +1,133 @@ +import json +import logging +import math +import re + +from bot.utils.ai_client import _get_connector +from bot.utils.database import get_summaries, save_conversation_summary +from config import ROUTERAI_API_KEY, ROUTERAI_BASE_URL + +logger = logging.getLogger(__name__) + +EMBEDDING_MODEL = "openai/text-embedding-3-small" +EMBEDDING_URL = f"{ROUTERAI_BASE_URL}/embeddings" + + +async def create_embedding(text: str) -> list[float] | None: + if not ROUTERAI_API_KEY: + logger.warning("Embedding skipped | ROUTERAI_API_KEY not set") + return None + + headers = { + "Authorization": f"Bearer {ROUTERAI_API_KEY}", + "Content-Type": "application/json", + } + + payload = { + "model": EMBEDDING_MODEL, + "input": text[:8000], + "encoding_format": "float", + } + + import aiohttp + from bot.utils.ai_client import get_client_timeout + + connector = _get_connector() + + try: + async with aiohttp.ClientSession(timeout=get_client_timeout(30), connector=connector) as session: + async with session.post(EMBEDDING_URL, json=payload, headers=headers) as response: + if response.status != 200: + error_body = await response.text() + logger.warning("Embedding error | status=%s error=%s", response.status, error_body[:200]) + return None + + data = await response.json() + embedding = data["data"][0]["embedding"] + logger.info("Embedding created | dim=%d input_len=%d", len(embedding), min(len(text), 8000)) + return embedding + except Exception as e: + logger.error("Embedding request error: %s", e) + return None + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(y * y for y in b)) + if norm_a == 0 or norm_b == 0: + return 0.0 + return dot / (norm_a * norm_b) + + +async def find_relevant_summaries(user_id: int, chat_id: int, query: str, top_k: int = 3) -> list[str]: + query_emb = await create_embedding(query) + if not query_emb: + return [] + + summaries = await get_summaries(user_id, chat_id, limit=20) + if not summaries: + logger.info("Relevant summaries | user=%d chat=%d no summaries found", user_id, chat_id) + return [] + + scored = [] + for s in summaries: + if not s["embedding"]: + continue + try: + emb = json.loads(s["embedding"]) + score = cosine_similarity(query_emb, emb) + scored.append((score, s["summary"])) + except Exception: + continue + + scored.sort(key=lambda x: x[0], reverse=True) + top = [text for _, text in scored[:top_k]] + top_score = scored[0][0] if scored else 0 + + logger.info( + "Relevant summaries | user=%d chat=%d found=%d top_score=%.3f", + user_id, chat_id, len(top), top_score, + ) + return top + + +async def save_summary_with_embedding(user_id: int, chat_id: int, summary_text: str) -> None: + emb = await create_embedding(summary_text) + embedding_json = json.dumps(emb) if emb else None + await save_conversation_summary(user_id, chat_id, summary_text, embedding_json) + logger.info( + "Summary saved | user=%d chat=%d summary_len=%d emb=%s", + user_id, chat_id, len(summary_text), "yes" if emb else "no", + ) + + +async def generate_summary(messages: list[dict]) -> str | None: + from bot.utils.ai_client import ask_ai_simple + + messages_text = "\n".join( + f"{'Пользователь' if m['role'] == 'user' else 'Астра'}: {m['content'][:300]}" + for m in messages[-50:] + ) + + prompt = ( + "Сделай краткую выжимку этого диалога (3-5 предложений). " + "Выдели ключевые темы, факты и предпочтения пользователя:\n\n" + f"{messages_text}" + ) + + try: + summary = await ask_ai_simple(prompt) + if summary and len(summary) > 20: + cleaned = re.sub(r"<[^>]+>", "", summary) + cleaned = cleaned.strip() + logger.info("Summary generated | len=%d", len(cleaned)) + return cleaned + elif summary: + logger.warning("Summary too short | len=%d", len(summary)) + else: + logger.warning("Summary generation returned None") + except Exception as e: + logger.error("Summary generation error: %s", e) + + return None diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py new file mode 100644 index 0000000..d5d6789 --- /dev/null +++ b/bot/utils/moderation.py @@ -0,0 +1,41 @@ +import time +from collections import defaultdict + + +class ModerationManager: + def __init__(self, limit: int, window: int, ban_duration: int): + self.limit = limit + self.window = window + self.ban_duration = ban_duration + self.counters: dict[int, list[float]] = defaultdict(list) + self.bans: dict[int, float] = {} + + def add_message(self, user_id: int) -> None: + now = time.time() + self.counters[user_id].append(now) + self.counters[user_id] = [ + t for t in self.counters[user_id] if now - t <= self.window + ] + + def is_banned(self, user_id: int) -> bool: + if user_id in self.bans: + if time.time() - self.bans[user_id] < self.ban_duration: + return True + del self.bans[user_id] + return False + + def check_and_ban(self, user_id: int) -> bool: + if len(self.counters.get(user_id, [])) >= self.limit: + self.bans[user_id] = time.time() + return True + return False + + def should_delete(self, user_id: int) -> bool: + return self.is_banned(user_id) + + +moderation = ModerationManager( + limit=25, + window=60, + ban_duration=300, +) diff --git a/bot/utils/s3_client.py b/bot/utils/s3_client.py new file mode 100644 index 0000000..bd8b960 --- /dev/null +++ b/bot/utils/s3_client.py @@ -0,0 +1,36 @@ +import os +import logging + +import boto3 +from botocore.config import Config + +from config import ACCESS_KEY, SECRET_KEY, BUCKET_NAME + +logger = logging.getLogger("s3_client") + +ENDPOINT_URL = "https://storage.yandexcloud.net" + +def _build_s3_client(): + return boto3.client( + "s3", + endpoint_url=ENDPOINT_URL, + aws_access_key_id=ACCESS_KEY, + aws_secret_access_key=SECRET_KEY, + config=Config( + connect_timeout=30, + read_timeout=300, + retries={"max_attempts": 3}, + ), + ) + +def upload_file(file_path: str) -> str | None: + key_name = os.path.basename(file_path) + try: + client = _build_s3_client() + client.upload_file(file_path, BUCKET_NAME, key_name) + file_url = f"{ENDPOINT_URL}/{BUCKET_NAME}/{key_name}" + logger.info("Файл загружен в S3: %s", file_url) + return file_url + except Exception as exc: + logger.exception("Ошибка загрузки в S3: %s", exc) + return None diff --git a/bot/utils/voice.py b/bot/utils/voice.py new file mode 100644 index 0000000..ff66232 --- /dev/null +++ b/bot/utils/voice.py @@ -0,0 +1,112 @@ +import asyncio +import logging +import os +import re +from pathlib import Path + +from faster_whisper import WhisperModel +from aiogram import Bot + +from config import PROXY_ENABLED, PROXY_URL + +logger = logging.getLogger(__name__) + +_model = None + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +MODELS_DIR = BASE_DIR / "models" / "faster-whisper" +VOICE_DIR = BASE_DIR / "bot" / "data" / "voice" + + +def _setup_proxy(): + if PROXY_ENABLED and PROXY_URL: + os.environ["ALL_PROXY"] = PROXY_URL + logger.info("Proxy set for model download: %s", PROXY_URL[:30] + "...") + + +def _get_model() -> WhisperModel: + global _model + if _model is None: + MODELS_DIR.mkdir(parents=True, exist_ok=True) + VOICE_DIR.mkdir(parents=True, exist_ok=True) + model_path = str(MODELS_DIR / "base") + if not os.path.isdir(model_path) or not os.path.isfile(os.path.join(model_path, "model.bin")): + _setup_proxy() + logger.info("Model not found locally, downloading to %s...", MODELS_DIR) + _model = WhisperModel( + "base", + device="cpu", + cpu_threads=4, + compute_type="int8", + download_root=str(MODELS_DIR), + ) + else: + logger.info("Loading model from %s...", model_path) + _model = WhisperModel( + model_path, + device="cpu", + cpu_threads=4, + compute_type="int8", + ) + logger.info("Whisper model loaded") + return _model + + +async def download_voice(bot: Bot, file_id: str) -> str: + VOICE_DIR.mkdir(parents=True, exist_ok=True) + file = await bot.get_file(file_id) + path = str(VOICE_DIR / f"{file_id}.ogg") + await bot.download_file(file.file_path, destination=path) + logger.info("Voice downloaded: %s -> %s", file_id, path) + return path + + +async def convert_to_wav(input_path: str) -> str: + output_path = input_path.replace(".ogg", ".wav") + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-i", input_path, "-ar", "16000", "-ac", "1", + "-c:a", "pcm_s16le", output_path, "-y", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + logger.error(f"ffmpeg error: {stderr.decode(errors='replace')}") + raise RuntimeError("ffmpeg conversion failed") + logger.info(f"Converted to WAV: {output_path}") + return output_path + + +async def transcribe_audio(file_path: str) -> str: + model = _get_model() + loop = asyncio.get_running_loop() + + def _transcribe(): + segments, info = model.transcribe(file_path, language="ru", beam_size=5) + text = " ".join(seg.text for seg in segments) + return text.strip() + + text = await loop.run_in_executor(None, _transcribe) + logger.info(f"Transcription result ({len(text)} chars): {text[:100]}...") + return text + + +def normalize_text(text: str) -> str: + text = text.strip() + if not text: + return text + text = text[0].upper() + text[1:] + if text[-1] not in ".!?…": + text += "." + text = re.sub(r"\s+", " ", text) + return text + + +async def cleanup_files(*paths: str): + for path in paths: + if path and os.path.exists(path): + try: + os.unlink(path) + logger.debug(f"Cleaned up: {path}") + except Exception as e: + logger.warning(f"Cleanup failed for {path}: {e}") diff --git a/bot/utils/weather.py b/bot/utils/weather.py new file mode 100644 index 0000000..a550a06 --- /dev/null +++ b/bot/utils/weather.py @@ -0,0 +1,118 @@ +import logging +from datetime import datetime + +import aiohttp +from aiohttp_socks import ProxyConnector, ProxyType + +from config import API_WEATHER, PROXY_ENABLED, PROXY_URL +from bot.bot import bot + +logger = logging.getLogger(__name__) + + +def get_wind_direction(deg: float) -> str: + if deg >= 337.5 or deg < 22.5: + return "Север" + if deg < 67.5: + return "Северо-восток" + if deg < 112.5: + return "Восток" + if deg < 157.5: + return "Юго-восток" + if deg < 202.5: + return "Юг" + if deg < 247.5: + return "Юго-запад" + if deg < 292.5: + return "Запад" + return "Северо-запад" + + +def _get_connector(): + if PROXY_ENABLED and PROXY_URL: + parsed = PROXY_URL.replace("socks5://", "").replace("socks5h://", "") + if "@" in parsed: + auth, host_port = parsed.split("@", 1) + username, password = auth.split(":", 1) + else: + username = None + password = None + host_port = parsed + + host, port = host_port.rsplit(":", 1) + port = int(port) + + return ProxyConnector( + proxy_type=ProxyType.SOCKS5, + host=host, + port=port, + username=username, + password=password, + ) + return None + + +async def get_weather(message) -> None: + if len(message.text.split(maxsplit=1)) == 1: + await bot.send_message(message.chat.id, "Пожалуйста, укажите город.") + return + + city = message.text.split(maxsplit=1)[1].strip() + url = "https://api.openweathermap.org/data/2.5/weather" + params = {"q": city, "appid": API_WEATHER, "units": "metric", "lang": "ru"} + + timeout = aiohttp.ClientTimeout(total=30, sock_connect=15, sock_read=15) + connector = _get_connector() + + try: + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + async with session.get(url, params=params) as response: + if response.status == 404: + await bot.send_message(message.chat.id, "Город не найден. Пожалуйста, уточните запрос.") + return + response.raise_for_status() + weather_data = await response.json() + + weather_description = weather_data["weather"][0]["description"] + temperature = weather_data["main"]["temp"] + feels_like = weather_data["main"]["feels_like"] + temp_min = weather_data["main"]["temp_min"] + temp_max = weather_data["main"]["temp_max"] + humidity = weather_data["main"]["humidity"] + pressure = int(weather_data["main"]["pressure"] / 1.333) + wind_speed = weather_data["wind"]["speed"] + wind_deg = weather_data["wind"].get("deg", 0) + wind_direction = get_wind_direction(wind_deg) + rain_1h = weather_data.get("rain", {}).get("1h", 0) + clouds_all = weather_data["clouds"]["all"] + visibility = weather_data.get("visibility", 0) + sunrise_time = datetime.fromtimestamp(weather_data["sys"]["sunrise"]).strftime("%H:%M") + sunset_time = datetime.fromtimestamp(weather_data["sys"]["sunset"]).strftime("%H:%M") + + weather_message = ( + f"Погода в городе {city}:\n\n" + f"Описание: {weather_description}\n" + f"Температура: {temperature}°C (ощущается как {feels_like}°C)\n" + f"Минимальная температура: {temp_min}°C\n" + f"Максимальная температура: {temp_max}°C\n" + f"Влажность: {humidity}%\n" + f"Давление: {pressure} мм рт.ст\n" + f"Скорость ветра: {wind_speed} м/с, направление: {wind_direction}\n" + f"Осадки за последний час: {rain_1h} мм\n" + f"Облачность: {clouds_all}%\n" + f"Видимость: {visibility} м\n" + f"Восход: {sunrise_time}, закат: {sunset_time}" + ) + await bot.send_message(message.chat.id, weather_message, parse_mode=None) + except aiohttp.ClientResponseError as exc: + logger.error("HTTP ошибка погоды: %s", exc) + await bot.send_message(message.chat.id, "При получении данных произошла ошибка, попробуйте еще раз.") + except aiohttp.ClientError as exc: + logger.error("Ошибка запроса погоды: %s", exc) + await bot.send_message(message.chat.id, "Не удалось связаться с погодным сервисом.") + except Exception as exc: + logger.exception("Неожиданная ошибка погоды: %s", exc) + await bot.send_message(message.chat.id, "Произошла ошибка при обработке погоды.") + finally: + if connector: + await connector.close() diff --git a/bot/utils/yadisk_download.py b/bot/utils/yadisk_download.py new file mode 100644 index 0000000..a3ae5b9 --- /dev/null +++ b/bot/utils/yadisk_download.py @@ -0,0 +1,84 @@ +import os +import re +import tempfile +from pathlib import Path +from urllib.parse import urlencode, unquote + +import aiohttp +from aiohttp_socks import ProxyConnector, ProxyType + +from config import PROXY_URL, PROXY_ENABLED + +DOWNLOAD_DIR = Path("bot/data/downloads") +DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + +def _get_connector(): + if PROXY_ENABLED and PROXY_URL: + parsed = PROXY_URL.replace("socks5://", "").replace("socks5h://", "") + if "@" in parsed: + auth, host_port = parsed.split("@", 1) + username, password = auth.split(":", 1) + else: + username = None + password = None + host_port = parsed + + host, port = host_port.rsplit(":", 1) + port = int(port) + + return ProxyConnector( + proxy_type=ProxyType.SOCKS5, + host=host, + port=port, + username=username, + password=password, + ) + return None + +def sanitize_filename(filename: str) -> str: + filename = os.path.basename(filename).strip() + filename = re.sub(r"[\\/:*?\"<>|]+", "_", filename) + return filename or "downloaded_file" + +async def download_yandex_file(public_url: str, progress_callback=None) -> str: + base_url = "https://cloud-api.yandex.net/v1/disk/public/resources/download?" + final_url = base_url + urlencode({"public_key": public_url}) + + timeout = aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=300) + connector = _get_connector() + + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + async with session.get(final_url) as response: + response.raise_for_status() + payload = await response.json() + download_url = payload["href"] + + async with session.get(download_url) as response: + response.raise_for_status() + + content_disposition = response.headers.get("Content-Disposition", "") + filename = download_url.split("/")[-1] + + if "filename*" in content_disposition: + try: + encoded = content_disposition.split("filename*=")[1].strip() + parts = encoded.split("''", 1) + if len(parts) == 2: + filename = unquote(parts[1], encoding=parts[0] or "utf-8") + except Exception: + pass + + filename = sanitize_filename(filename) + suffix = Path(filename).suffix or ".bin" + + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=str(DOWNLOAD_DIR)) as tmp_file: + total_size = int(response.headers.get("Content-Length", 0)) + downloaded_size = 0 + + async for chunk in response.content.iter_chunked(1024 * 64): + tmp_file.write(chunk) + downloaded_size += len(chunk) + if progress_callback: + await progress_callback(downloaded_size, total_size) + + return tmp_file.name diff --git a/config.py b/config.py new file mode 100644 index 0000000..204e985 --- /dev/null +++ b/config.py @@ -0,0 +1,38 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +BOT_TOKEN = os.getenv("BOT_TOKEN", "") + +PROXY_ENABLED = os.getenv("PROXY_ENABLED", "false").lower() == "true" +PROXY_URL = os.getenv("PROXY_URL", "") +API_WEATHER = os.getenv("API_WEATHER", "") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") +ROUTERAI_API_KEY = os.getenv("ROUTERAI_API_KEY", "") +ROUTERAI_BASE_URL = os.getenv("ROUTERAI_BASE_URL", "https://routerai.ru/api/v1") +ROUTERAI_MODEL = "deepseek/deepseek-v4-flash" + +MODERATION_LIMIT = 25 +MODERATION_WINDOW = 60 +MODERATION_BAN_DURATION = 300 + +AI_CONTEXT_LIMIT = 10 +AI_DIALOGUE_LIMIT = 50 +AI_PHASE2_LIMIT = 20 +AI_COOLDOWN = 3600 +AI_BLOCK_DEFAULT_DURATION = 86400 + +ACCESS_KEY = os.getenv("ACCESS_KEY", "").strip() +SECRET_KEY = os.getenv("SECRET_KEY", "").strip() +BUCKET_NAME = os.getenv("BUCKET_NAME", "").strip() + +AI_SYSTEM_PROMPT = ( + "Ты — Астра, универсальный справочный AI-помощник в Telegram чате. " + "Твоя задача — помогать искать информацию, объяснять сложные вещи просто. " + "Отвечай кратко, чётко, с основной сутью. Если пользователь не попросил развёрнутый ответ — не расписывай. " + "Если контекст разговора закончился и ты потеряла нить, скажи что-то милое и отвлекающее, " + 'например "я потеряла мысль" или "смотри, какая птичка!".' +) + +DATABASE_URL = "sqlite+aiosqlite:///./bot/data/umb.db" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e671c1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + umb-bot: + build: . + container_name: umb-bot + restart: unless-stopped + env_file: .env + volumes: + - ./bot/data:/app/bot/data diff --git a/main.py b/main.py new file mode 100644 index 0000000..b7119d2 --- /dev/null +++ b/main.py @@ -0,0 +1,78 @@ +import asyncio +import logging + +from aiogram.exceptions import TelegramNetworkError +from aiohttp import ClientConnectionError + +from aiogram import F +from aiogram.types import Message +from aiogram.dispatcher.event.bases import SkipHandler + +from bot.bot import dp, bot, setup_proxy +from bot.routers import moderation, layout, weather, ai, voice, yadisk, dialogue +from bot.utils.database import init_db, save_chat_user +from bot.utils.ai_client import start_model_health_check +from bot.utils.logging_config import setup_logging +from bot.setup_commands import setup_bot_commands +from config import PROXY_ENABLED + +setup_logging() +logger = logging.getLogger(__name__) + +dp.include_router(moderation.router) +dp.include_router(layout.router) +dp.include_router(weather.router) +dp.include_router(ai.router) +dp.include_router(voice.router) +dp.include_router(yadisk.router) +dp.include_router(dialogue.router) + + +@dp.message(F.text) +async def save_user_info(message: Message): + try: + await save_chat_user( + message.from_user.id, + message.chat.id, + message.from_user.username, + message.from_user.full_name or "", + ) + except Exception: + pass + raise SkipHandler() + + +async def main(): + await init_db() + logging.info("База данных инициализирована") + await setup_bot_commands() + + asyncio.create_task(start_model_health_check()) + + try: + if PROXY_ENABLED: + await setup_proxy() + else: + me = await bot.me() + logging.info(f"Бот запущен без прокси: @{me.username}") + except TelegramNetworkError as e: + if PROXY_ENABLED: + logging.error("Не могу соединиться с прокси сервером. Попробуй другой прокси.") + else: + logging.error("Не могу соединиться с Telegram. Проверь интернет-соединение.") + return + except ClientConnectionError as e: + if PROXY_ENABLED: + logging.error("Не могу соединиться с прокси сервером. Попробуй другой прокси.") + else: + logging.error("Не могу соединиться с Telegram. Проверь интернет-соединение.") + return + except Exception as e: + logging.error(f"Ошибка при запуске бота: {e}") + return + + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6cea768 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +aiogram>=3.0.0 +python-dotenv>=1.0.0 +aiohttp-socks>=0.8.0 +sqlalchemy>=2.0.0 +aiosqlite>=0.19.0 +faster-whisper>=1.1.0 +httpx[socks] +boto3>=1.34.0