refactor: review fixes and code improvements

- Add shared proxy module (bot/utils/proxy.py) to eliminate duplicate SOCKS5 parsing
- Fix AI client: escape HTML before Markdown→HTML conversion, unify timeouts,
  make health check optional and disabled by default, handle 429 retries
- Fix ai.py: correct forwarded message handling (aiogram 3.x forward_origin),
  pass relevant summaries via extra_system_content
- Fix dialogue.py: only respond to Astra's messages, use system context instead
  of prompt injection, answer on the limit message before phase transition
- Fix voice.py: load Whisper model in thread pool, safe WAV path generation
- Improve database.py: composite indexes, Boolean is_active, upsert file_id cache,
  add context cleanup helper
- Update weather.py and yadisk_download.py to use shared proxy connector
- Update yadisk.py: validate URL before cache clear, add download size limit,
  wrap sync file ops in to_thread
- Reuse S3 client via lru_cache
- Update setup_commands with /aiclear and /aiuser
- Update README, Dockerfile (Python 3.11), docker-compose (mount models)
- Pin dependency versions, remove unused httpx[socks]
- Add basic pytest tests for layout converter and voice normalization
This commit is contained in:
Галингер Р.С.
2026-07-07 18:42:10 +07:00
parent 76e5701eba
commit 0f674f8832
23 changed files with 894 additions and 578 deletions
+135 -47
View File
@@ -1,4 +1,5 @@
import logging
import time
from aiogram import Router, F
from aiogram.types import Message
@@ -15,11 +16,19 @@ from bot.utils.database import (
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
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__)
@@ -48,8 +57,8 @@ async def get_user_id_by_username(message: Message, username: str) -> int | None
if username_clean.isdigit():
return int(username_clean)
except Exception as e:
logger.error("Error resolving username %s: %s", username, e)
except Exception as exc:
logger.error("Error resolving username %s: %s", username, exc)
return None
@@ -62,8 +71,8 @@ async def _is_creator(message: Message) -> bool:
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)
except Exception as exc:
logger.error("Error checking creator: %s", exc)
return False
@@ -76,10 +85,18 @@ 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 "")
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)
await message.answer(
"Тебе временно недоступен AI. Обратись к владельцу чата.",
parse_mode=None,
)
return
text_parts = message.text.split(maxsplit=1)
@@ -88,12 +105,21 @@ async def cmd_ai(message: Message):
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)
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
elif message.forward_origin:
target_text = message.text or message.caption
if not target_text:
await message.answer(
"Могу работать только с текстовыми сообщениями.",
parse_mode=None,
)
return
prompt = f"Проанализируй это сообщение:\n\n{target_text}"
context_text = target_text
elif has_direct_question:
@@ -107,21 +133,26 @@ async def cmd_ai(message: Message):
context = await get_user_context(user_id, chat_id, AI_CONTEXT_LIMIT)
extra_system_content = None
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)
extra_system_content = "Из прошлых диалогов:\n" + "\n---\n".join(relevant)
except Exception as exc:
logger.error("Error fetching relevant summaries: %s", exc)
async def update_status(text: str):
try:
await status_msg.edit_text(text, parse_mode=None)
except Exception:
pass
except Exception as exc:
logger.debug("Status update failed: %s", exc)
response = await ask_ai(prompt, context, status_callback=update_status)
response = await ask_ai(
prompt,
context,
status_callback=update_status,
extra_system_content=extra_system_content,
)
if not response:
response = "Не удалось получить ответ от AI."
@@ -130,9 +161,14 @@ async def cmd_ai(message: Message):
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)
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)
try:
await status_msg.edit_text(response, parse_mode=None, disable_web_page_preview=True)
except Exception as exc:
logger.warning("Failed to edit status message: %s", exc)
async def _start_dialogue(message: Message):
@@ -142,8 +178,10 @@ async def _start_dialogue(message: Message):
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
remaining = (
int(dialogue["blocked_until"] - time.time()) if dialogue["blocked_until"] else 0
)
mins = max(0, remaining // 60)
await message.answer(
f"Твой лимит диалога исчерпан. Попробуй через {mins} мин.",
parse_mode=None,
@@ -182,10 +220,13 @@ async def cmd_aiuser(message: Message):
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)
await message.answer("✅ Список отправлен в ЛС.", parse_mode=None)
except Exception as exc:
logger.error("aiuser error: %s", exc)
await message.answer(
"Не удалось получить список пользователей. Возможно, у бота нет доступа.",
parse_mode=None,
)
@router.message(Command("aiclear"))
@@ -195,28 +236,49 @@ async def cmd_aiclear(message: Message):
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)
cnt = len(context_messages)
if 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))
await status_msg.edit_text(
"✅ Выжимка сохранена. Диалог очищен.", parse_mode=None
)
logger.info(
"aiclear | user=%d chat=%d context=%d summary=%d",
user_id,
chat_id,
cnt,
len(summary),
)
else:
logger.warning("aiclear | user=%d chat=%d context=%d summary=None", user_id, chat_id, cnt)
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)
logger.warning(
"aiclear | user=%d chat=%d not enough context (%d < 2)",
user_id,
chat_id,
cnt,
)
except Exception as exc:
logger.error("aiclear summary error: %s", exc)
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)
await message.answer(
"✅ Диалог очищен. Можешь начать новый.",
parse_mode=None,
)
@router.message(Command("aino"))
@@ -226,16 +288,30 @@ async def cmd_aino(message: Message):
username = extract_username(message.text)
if not username:
await message.answer("Укажи пользователя: /aino @username или /aino id<UID>", parse_mode=None)
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)
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)
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"))
@@ -245,16 +321,28 @@ async def cmd_aiyes(message: Message):
username = extract_username(message.text)
if not username:
await message.answer("Укажи пользователя: /aiyes @username или /aiyes id<UID>", parse_mode=None)
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)
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)
await message.answer(
f"Пользователь {username} разблокирован для AI.",
parse_mode=None,
)
else:
await message.answer(f"Пользователь {username} не был заблокирован от AI.", parse_mode=None)
await message.answer(
f"Пользователь {username} не был заблокирован от AI.",
parse_mode=None,
)
+86 -46
View File
@@ -4,6 +4,7 @@ import time
from aiogram import Router, F
from aiogram.types import Message
from bot.bot import bot
from bot.utils.ai_client import ask_ai
from bot.utils.database import (
add_context_message,
@@ -16,7 +17,11 @@ from bot.utils.database import (
save_chat_user,
clear_user_context,
)
from bot.utils.memory import find_relevant_summaries, save_summary_with_embedding, generate_summary
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__)
@@ -24,19 +29,34 @@ logger = logging.getLogger(__name__)
router = Router()
async def _is_astra_message(replied: Message) -> bool:
"""Check whether the replied message was sent by this bot (Astra)."""
if not replied.from_user or not replied.from_user.is_bot:
return False
try:
me = await bot.me()
return replied.from_user.id == me.id
except Exception as exc:
logger.warning("Could not verify bot identity: %s", exc)
return False
@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 "")
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:
if not await _is_astra_message(replied):
return
dialogue = await get_or_create_dialogue(user_id, chat_id)
@@ -48,64 +68,77 @@ async def handle_dialogue_reply(message: Message, replied: Message):
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)
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
limit = AI_DIALOGUE_LIMIT if phase == 1 else AI_PHASE2_LIMIT
if current_count > limit:
# We allow answering on the exact limit message; the next one triggers phase/cooldown.
should_transition = current_count > limit
if should_transition:
if phase == 1:
logger.info("Dialogue phase1→2 | user=%d chat=%d msg_count=%d", user_id, chat_id, current_count)
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)
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_limit = AI_CONTEXT_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)
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)
except Exception as exc:
logger.error("Error fetching relevant summaries: %s", exc)
remaining = limit - current_count
warning = ""
if remaining <= 5:
if 0 < remaining <= 5:
warning = f"\n\n⚠️ Осталось {remaining} сообщений в этом диалоге."
system_prompt_extra = ""
system_extras = []
if extra_context:
system_prompt_extra += extra_context
system_extras.append(extra_context)
if warning:
system_prompt_extra += warning
system_extras.append(warning)
message_text = message.text or ""
if system_prompt_extra:
message_text += "\n\n(Контекст)" + system_prompt_extra
extra_system_content = "\n".join(system_extras) if system_extras else None
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
except Exception as exc:
logger.debug("Status update failed: %s", exc)
response = await ask_ai(
message_text,
message.text or "",
context_messages,
status_callback=update_status,
extra_system_content=extra_system_content,
)
if not response:
@@ -121,21 +154,23 @@ async def handle_dialogue_reply(message: Message, replied: Message):
try:
await status_msg.delete()
except Exception:
pass
except Exception as exc:
logger.debug("Status delete failed: %s", exc)
if remaining <= 3 and remaining > 0:
if 0 < remaining <= 3:
try:
warn_msg = await message.reply(
await message.reply(
f"⚠️ Осталось {remaining} сообщений. Память почти заполнена.",
parse_mode=None,
)
except Exception:
pass
except Exception as exc:
logger.debug("Warning message failed: %s", exc)
async def _transition_to_phase2(message: Message, user_id: int, chat_id: int):
status_msg = await message.answer("Сохраняю выжимку диалога...", parse_mode=None)
status_msg = await message.answer(
"Сохраняю выжимку диалога...", parse_mode=None
)
try:
context = await get_user_context(user_id, chat_id, limit=AI_DIALOGUE_LIMIT)
@@ -144,15 +179,17 @@ async def _transition_to_phase2(message: Message, user_id: int, chat_id: int):
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)
except Exception as exc:
logger.error("Error saving summary: %s", exc)
await reset_dialogue_to_phase2(user_id, chat_id)
try:
await status_msg.edit_text("✅ Начинаю новую сессию (осталось 20 сообщений).", parse_mode=None)
except Exception:
pass
await status_msg.edit_text(
"✅ Начинаю новую сессию (осталось 20 сообщений).", parse_mode=None
)
except Exception as exc:
logger.debug("Status edit failed: %s", exc)
async def _end_dialogue(message: Message, user_id: int, chat_id: int):
@@ -162,9 +199,12 @@ async def _end_dialogue(message: Message, user_id: int, chat_id: int):
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)
except Exception as exc:
logger.error("Error saving final summary: %s", exc)
await block_dialogue(user_id, chat_id, AI_COOLDOWN)
await message.reply("Твой лимит исчерпан. Возвращайся через час.", parse_mode=None)
await message.reply(
"Твой лимит исчерпан. Возвращайся через час.",
parse_mode=None,
)
+25 -2
View File
@@ -1,9 +1,17 @@
import logging
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 bot.utils.database import (
add_sticker_message,
ban_user_stickers,
is_user_sticker_banned,
)
from config import MODERATION_LIMIT
logger = logging.getLogger(__name__)
router = Router()
@@ -13,13 +21,28 @@ async def handle_sticker_or_gif(message: Message):
chat_id = message.chat.id
if await is_user_sticker_banned(user_id, chat_id):
await message.delete()
try:
await message.delete()
logger.info(
"Deleted sticker/gif from banned user | user=%d chat=%d",
user_id,
chat_id,
)
except Exception as exc:
logger.warning("Could not delete sticker/gif: %s", exc)
return
count = await add_sticker_message(user_id, chat_id)
logger.debug("Sticker/gif count | user=%d chat=%d count=%d", user_id, chat_id, count)
if count >= MODERATION_LIMIT:
await ban_user_stickers(user_id, chat_id)
logger.info(
"Sticker/gif ban triggered | user=%d chat=%d count=%d",
user_id,
chat_id,
count,
)
await message.answer(
f"⚠️ {message.from_user.full_name}, вы превысили лимит стикеров/GIF!\n"
"Отправка стикеров и GIF ограничена на 5 минут.",
+24 -13
View File
@@ -14,6 +14,7 @@ router = Router()
logger = logging.getLogger("yadisk")
MAX_TELEGRAM_FILE_SIZE = 50 * 1024 * 1024
MAX_DOWNLOAD_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB safety limit
def is_valid_yandex_public_link(value: str) -> bool:
@@ -35,19 +36,21 @@ async def yandex_download_handler(message: Message):
await message.answer("Укажите ссылку: /ydf https://yadi.sk/...")
return
url = parts[1].strip()
raw_arg = parts[1].strip()
force_refresh = raw_arg.startswith("(new)")
url = raw_arg.replace("(new)", "", 1).strip() if force_refresh else raw_arg
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 url:
await message.answer("После (new) укажите ссылку.")
return
if not is_valid_yandex_public_link(url):
await message.answer("Это не похоже на публичную ссылку Яндекс.Диска.")
return
if force_refresh:
await save_file_id(url, None)
cached_id = await get_file_id(url)
if cached_id:
await message.answer_document(
@@ -63,6 +66,8 @@ async def yandex_download_handler(message: Message):
nonlocal last_text
if total <= 0:
return
if total > MAX_DOWNLOAD_SIZE:
raise RuntimeError("Файл слишком большой для загрузки.")
pct = int(downloaded / total * 100)
new_text = f"Загрузка: {pct}%"
if new_text != last_text and pct % 5 == 0:
@@ -72,15 +77,20 @@ async def yandex_download_handler(message: Message):
except Exception:
pass
file_path = None
try:
file_path = await download_yandex_file(url, progress_callback=progress)
except Exception as exc:
logger.exception("Ошибка скачивания с Яндекс.Диска")
await status_msg.edit_text("Не удалось скачать файл. Проверьте ссылку.")
await status_msg.edit_text(f"Не удалось скачать файл: {exc}")
return
try:
file_size = os.path.getsize(file_path)
file_size = await asyncio.to_thread(os.path.getsize, file_path)
if file_size > MAX_DOWNLOAD_SIZE:
await status_msg.edit_text("Файл превышает максимально допустимый размер.")
return
if file_size < MAX_TELEGRAM_FILE_SIZE:
doc = FSInputFile(file_path)
@@ -97,7 +107,8 @@ async def yandex_download_handler(message: Message):
else:
await status_msg.edit_text("Не удалось загрузить файл в облако.")
finally:
try:
os.remove(file_path)
except OSError:
pass
if file_path:
try:
await asyncio.to_thread(os.remove, file_path)
except OSError:
pass