501 lines
22 KiB
Python
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
|