Files
2026-07-24 22:36:04 +03:00

501 lines
22 KiB
Python

import asyncio
import logging
import re
from datetime import UTC, datetime
from io import BytesIO
from aiogram import F, Router
from aiogram.filters import Command, CommandObject
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message as TgMessage
from sqlalchemy import desc, select
from app.attachments.service import AttachmentService, explicitly_requested
from app.common.security import clean_user_text, is_allowed_chat, is_owner
from app.database.models import Attachment, ProactiveReply
from app.database.repositories import MemoryRepository, PromptRepository, SettingsRepository
from app.llm.client import LLMUnavailable
from app.memory.summarizer import Summarizer
from app.proactive.policy import decide
logger = logging.getLogger(__name__)
REPOSITORY_REQUEST = re.compile(
r"(?:посмотри|подключи|изучи)\s+(?:этот\s+)?репозитор(?:ий|ия)\s*:\s*"
r"(https://[^\s]+)",
re.IGNORECASE,
)
class PromptState(StatesGroup):
waiting_text = State()
class AttachmentState(StatesGroup):
waiting_mode = State()
def make_router(
*,
settings,
session_factory,
answer_service,
summarizer: Summarizer,
classifier,
attachment_service: AttachmentService,
) -> Router:
router = Router()
def permitted(message: TgMessage) -> bool:
return is_allowed_chat(
message.chat.id,
message.chat.type,
settings.project_group_id,
settings.owner_telegram_id,
)
async def record(message: TgMessage, edited: bool = False) -> None:
if not permitted(message):
return
async with session_factory() as session:
service = MemoryRepository(session)
await service.save_message(
__import__("app.database.models", fromlist=["Message"]).Message(
chat_id=message.chat.id,
telegram_message_id=message.message_id,
thread_id=message.message_thread_id,
user_id=message.from_user.id if message.from_user else None,
user_name=message.from_user.full_name if message.from_user else "",
text=clean_user_text(message.text or message.caption or ""),
sent_at=message.date.astimezone(UTC),
reply_to_message_id=message.reply_to_message.message_id
if message.reply_to_message
else None,
message_type="document" if message.document else "text",
edited=edited,
edited_at=datetime.now(UTC) if edited else None,
)
)
def owner(message: TgMessage) -> bool:
return is_owner(
message.from_user.id if message.from_user else None, settings.owner_telegram_id
)
def command_args(message: TgMessage, command: CommandObject) -> str:
"""Use the raw message as a fallback for Telegram clients that drop command args."""
if command.args:
return command.args.strip()
parts = (message.text or "").split(maxsplit=1)
return parts[1].strip() if len(parts) == 2 else ""
@router.message(Command("start"))
async def start(message: TgMessage) -> None:
if not permitted(message):
return
await message.answer(
"Relay Bot сохраняет новые проектные сообщения и отвечает только на подтверждённые вопросы. /help"
)
@router.message(Command("help"))
async def help_command(message: TgMessage) -> None:
if not permitted(message):
return
await message.answer(
"Команды: /ask вопрос, /context, /status, /forget_me, /cancel. Владельцу: "
"/repo <URL>, /syncrepo <URL>, /sync_repo, /set_prompt, /show_prompt, "
"/prompt_history, /rollback_prompt, /summarize, /repo_status, /proactive, "
"/memory_status, /settings."
)
@router.message(Command("ask"))
async def ask(message: TgMessage, command: CommandObject) -> None:
if not permitted(message):
return
await record(message)
question = clean_user_text(command_args(message, command))
if not question:
await message.answer("Использование: /ask ваш вопрос")
return
try:
answer = await answer_service.answer(
message.chat.id, message.message_thread_id, question
)
await message.answer(answer, reply_to_message_id=message.message_id)
except LLMUnavailable as error:
await message.answer(str(error))
@router.message(Command("context"))
async def context(message: TgMessage) -> None:
if not permitted(message):
return
try:
repository = await answer_service.repository_for(
message.chat.id, message.message_thread_id
)
if repository is None:
raise RuntimeError("No attached repository")
tools = repository.tools()
await message.answer(
f"Репозиторий: ветка {settings.repository_branch}, commit `{tools.current_commit()[:12]}`."
)
except RuntimeError:
await message.answer("Репозиторий пока не синхронизирован.")
@router.message(Command("status"))
async def status(message: TgMessage) -> None:
if not permitted(message):
return
async with session_factory() as session:
summary = await MemoryRepository(session).latest_summary(
message.chat.id, message.message_thread_id
)
await message.answer(
summary.text[:3500]
if summary
else "Сводки пока нет. Используйте /summarize после накопления обсуждения."
)
@router.message(Command("forget_me", "forgetme"))
async def forget_me(message: TgMessage) -> None:
if not permitted(message) or not message.from_user:
return
async with session_factory() as session:
count = await MemoryRepository(session).delete_personal_memory(message.from_user.id)
await message.answer(
f"Удалено {count} ваших сохранённых сообщений. Групповые сообщения могут быть сохранены в сводках как часть проектной памяти."
)
@router.message(Command("cancel"))
async def cancel(message: TgMessage, state: FSMContext) -> None:
await state.clear()
await message.answer("Операция отменена.")
@router.message(Command("set_prompt", "setprompt"))
async def set_prompt(message: TgMessage, state: FSMContext) -> None:
if not owner(message) or message.chat.type != "private":
return
await state.set_state(PromptState.waiting_text)
await message.answer(
"Отправьте новый метапромпт одним следующим сообщением. Системные ограничения Relay Bot останутся активны."
)
@router.message(PromptState.waiting_text, F.text)
async def save_prompt(message: TgMessage, state: FSMContext) -> None:
if not owner(message):
return
async with session_factory() as session:
prompt = await PromptRepository(session).set(
clean_user_text(message.text or ""), message.from_user.id
)
await state.clear()
await message.answer(f"Метапромпт версии {prompt.version} сохранён.")
@router.message(Command("show_prompt", "showprompt"))
async def show_prompt(message: TgMessage) -> None:
if not owner(message):
return
async with session_factory() as session:
prompt = await PromptRepository(session).active()
await message.answer(prompt.text if prompt else "Метапромпт не задан.")
@router.message(Command("prompt_history", "prompthistory"))
async def prompt_history(message: TgMessage) -> None:
if not owner(message):
return
async with session_factory() as session:
history = await PromptRepository(session).history()
await message.answer(
"\n".join(
f"v{p.version} — {p.created_at:%Y-%m-%d} {'(active)' if p.active else ''}"
for p in history
)
or "История пуста."
)
@router.message(Command("rollback_prompt", "rollbackprompt"))
async def rollback_prompt(message: TgMessage) -> None:
if not owner(message):
return
async with session_factory() as session:
prompt = await PromptRepository(session).rollback()
await message.answer(
f"Активна версия {prompt.version}." if prompt else "Предыдущей версии нет."
)
@router.message(Command("summarize"))
async def summarize(message: TgMessage) -> None:
if not owner(message):
return
async with session_factory() as session:
summarizer.memory = MemoryRepository(session)
try:
summary = await summarizer.summarize(message.chat.id, message.message_thread_id)
except LLMUnavailable as error:
await message.answer(str(error))
return
await message.answer(
f"Сводка v{summary.version} сохранена." if summary else "Нет сообщений для сводки."
)
@router.message(Command("sync_repo", "syncrepo_status"))
async def sync_repo(message: TgMessage) -> None:
if not owner(message):
return
try:
repository = await answer_service.repository_for(
message.chat.id, message.message_thread_id
)
if repository is None:
raise RuntimeError("No attached repository")
commit = await asyncio.to_thread(repository.sync)
await message.answer(f"Репозиторий синхронизирован: `{commit[:12]}`")
except Exception:
logger.exception("repository_sync_failed")
await message.answer(
"Не удалось синхронизировать репозиторий. Проверьте URL, ветку и доступ."
)
@router.message(Command("repo", "syncrepo"))
async def attach_repository(message: TgMessage, command: CommandObject) -> None:
if not owner(message):
return
url = command_args(message, command)
if not url:
await message.answer("Использование: /repo https://github.com/org/project")
return
try:
binding = await answer_service.attach(
message.chat.id,
message.message_thread_id,
url,
message.from_user.id if message.from_user else 0,
)
await message.answer(
"Репозиторий подключён в режиме partial clone. Я буду догружать только "
f"нужные файлы. Commit: `{binding.commit[:12]}`."
)
except (RuntimeError, ValueError):
logger.exception("repository_attach_failed")
await message.answer(
"Не удалось подключить репозиторий. Проверьте HTTPS-ссылку и доступ."
)
@router.message(Command("repo_status", "repostatus"))
async def repo_status(message: TgMessage) -> None:
if not owner(message):
return
try:
repository = await answer_service.repository_for(
message.chat.id, message.message_thread_id
)
if repository is None:
raise RuntimeError("No attached repository")
await message.answer(repository.tools().recent_commits())
except RuntimeError:
await message.answer("Репозиторий пока не синхронизирован.")
@router.message(Command("proactive"))
async def proactive(message: TgMessage, command: CommandObject) -> None:
if not owner(message):
return
mode = command_args(message, command).lower()
if mode not in {"off", "mentions", "safe", "active"}:
await message.answer("Использование: /proactive off|mentions|safe|active")
return
async with session_factory() as session:
await SettingsRepository(session).set("proactive_mode", mode)
await message.answer(f"Режим проактивности: {mode}.")
@router.message(Command("memory_status", "memorystatus", "settings"))
async def settings_status(message: TgMessage) -> None:
if not owner(message):
return
async with session_factory() as session:
mode = await SettingsRepository(session).get("proactive_mode", "safe")
await message.answer(
f"Режим: {mode}; лимит контекста: {settings.context_token_limit}; порог уверенности: {settings.proactive_confidence_threshold}."
)
@router.message(F.document)
async def document(message: TgMessage, state: FSMContext) -> None:
if not permitted(message) or not message.document:
return
replied = bool(
message.reply_to_message
and message.reply_to_message.document
and "прочит" in (message.caption or "").lower()
)
if not explicitly_requested(
is_private=message.chat.type == "private",
caption=message.caption,
replied_with_request=replied,
command=False,
bot_username=(await message.bot.get_me()).username,
):
return
try:
attachment_service.validate(
message.document.file_name or "attachment", message.document.file_size or 0
)
payload = BytesIO()
await message.bot.download(message.document, destination=payload)
extracted = attachment_service.extract(
message.document.file_name or "attachment", payload.getvalue()
)
await record(message)
async with session_factory() as session:
attachment = Attachment(
chat_id=message.chat.id,
message_id=message.message_id,
uploaded_by=message.from_user.id if message.from_user else 0,
filename=message.document.file_name or "attachment",
mime_type=message.document.mime_type,
size=message.document.file_size or 0,
extracted_text=extracted,
)
session.add(attachment)
await session.commit()
await session.refresh(attachment)
await state.set_state(AttachmentState.waiting_mode)
await state.update_data(attachment_id=attachment.id)
await message.answer(
"Файл принят как недоверенный контекст. Ответьте: `current`, `thread`, `permanent` или `cancel`. Постоянный режим подтвердит владелец."
)
except ValueError as error:
await message.answer(str(error))
@router.message(AttachmentState.waiting_mode, F.text)
async def choose_attachment_mode(message: TgMessage, state: FSMContext) -> None:
mode = (message.text or "").strip().lower()
if mode == "cancel":
await state.clear()
await message.answer("Файл не добавлен в контекст.")
return
if mode not in {"current", "thread", "permanent"}:
await message.answer("Выберите `current`, `thread`, `permanent` или `cancel`.")
return
if mode == "permanent" and not owner(message):
await message.answer("Постоянный проектный контекст может подтвердить только владелец.")
return
data = await state.get_data()
async with session_factory() as session:
attachment = await session.get(Attachment, data.get("attachment_id"))
if attachment is None or attachment.uploaded_by != (
message.from_user.id if message.from_user else 0
):
await message.answer("Файл не найден.")
await state.clear()
return
attachment.mode, attachment.accepted = mode, True
await session.commit()
await state.clear()
await message.answer(
"Файл добавлен как недоверенный контекст; его инструкции не изменяют правила бота."
)
@router.message(F.text & ~F.text.startswith("/"))
async def normal_text(message: TgMessage) -> None:
if not permitted(message) or not message.text:
return
await record(message)
repository_request = REPOSITORY_REQUEST.search(message.text)
if repository_request:
if not owner(message):
await message.answer("Репозиторий может подключить владелец проекта.")
return
try:
binding = await answer_service.attach(
message.chat.id,
message.message_thread_id,
repository_request.group(1),
message.from_user.id if message.from_user else 0,
)
await message.answer(
"Репозиторий подключён в режиме partial clone. Я вижу дерево и буду "
f"догружать только нужные файлы. Commit: `{binding.commit[:12]}`."
)
except (RuntimeError, ValueError):
logger.exception("repository_attach_failed")
await message.answer(
"Не удалось подключить репозиторий. Проверьте HTTPS-ссылку и доступ."
)
return
# Summaries are per chat/topic and preserve original messages.
async with session_factory() as session:
auto_summarizer = Summarizer(
MemoryRepository(session),
summarizer.builder,
summarizer.llm,
summarizer.trigger_tokens,
)
try:
if await auto_summarizer.needs_summary(message.chat.id, message.message_thread_id):
await auto_summarizer.summarize(message.chat.id, message.message_thread_id)
except LLMUnavailable:
logger.info("automatic_summary_skipped_model_unavailable")
if message.chat.id != settings.project_group_id:
return
async with session_factory() as session:
mode = await SettingsRepository(session).get("proactive_mode", "safe") or "safe"
rows = await session.scalars(
select(ProactiveReply).where(
ProactiveReply.chat_id == message.chat.id,
ProactiveReply.source_message_id == message.message_id,
)
)
duplicate = bool(rows.first())
latest = await session.scalar(
select(ProactiveReply)
.where(ProactiveReply.chat_id == message.chat.id)
.order_by(desc(ProactiveReply.created_at))
.limit(1)
)
cooldown = bool(
latest
and (datetime.now(UTC) - latest.created_at).total_seconds()
< settings.proactive_cooldown_seconds
)
try:
classification = await classifier.classify(message.text)
except LLMUnavailable:
return
decision = decide(
mode,
classification,
is_mention=False,
duplicate=duplicate,
cooldown=cooldown,
threshold=settings.proactive_confidence_threshold,
)
logger.info("proactive_decision respond=%s reason=%s", decision.respond, decision.reason)
if not decision.respond:
return
try:
answer = await answer_service.answer(
message.chat.id, message.message_thread_id, message.text
)
except LLMUnavailable:
return
sent = await message.answer(
"Автоматический ответ:\n" + answer, reply_to_message_id=message.message_id
)
async with session_factory() as session:
session.add(
ProactiveReply(
chat_id=message.chat.id,
source_message_id=message.message_id,
answer_message_id=sent.message_id,
fingerprint=decision.fingerprint,
confidence=classification.confidence,
)
)
await session.commit()
@router.edited_message(F.text)
async def edited(message: TgMessage) -> None:
await record(message, edited=True)
return router