Initial commit: UMB Telegram Bot
This commit is contained in:
+26
@@ -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
|
||||
@@ -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<UID>", 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<UID>", 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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"<pre>\2</pre>", text, flags=re.DOTALL)
|
||||
text = re.sub(r"`(.*?)`", r"<code>\1</code>", text)
|
||||
text = re.sub(r"\*\*(.*?)\*\*", r"<b>\1</b>", text)
|
||||
text = re.sub(r"\*(.*?)\*", r"<i>\1</i>", text)
|
||||
text = re.sub(r"__(.*?)__", r"<u>\1</u>", text)
|
||||
text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
|
||||
text = re.sub(r"\[(.*?)\]\((.*?)\)", r'<a href="\2">\1</a>', 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 "Произошла ошибка при обработке запроса."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user