feat(bot): Коммит — создание, замена и удаление файлов, черновик

- новый файл: имя → содержимое текстом (```-обёртка снимается) или документом
- новая папка (виртуальная, пока в ней нет файлов), замена с diff +N/−M, удаление
- валидация: имя, ≤ 100 КБ, UTF-8, без NUL; .py — ast.parse с выбором «исправить / всё равно»;
  .sourcecraft/, .tgit/, ruff.toml из бота не меняются
- DraftService: черновик в БД, 24 ч, ≤ 50 изменений; удаление нового файла убирает его из черновика
- дерево показывает черновик поверх репозитория: 🆕 / ✏️ / 🗑, размер — как будет после пуша
- экран черновика (A/M/D) с «↩️ убрать»

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:18:49 +03:00
co-authored by Claude Opus 5.5
parent dbb17bd774
commit 614f8eafd5
11 changed files with 951 additions and 91 deletions
+22 -5
View File
@@ -7,9 +7,9 @@ from typing import Any
from aiogram import Bot, Dispatcher
from aiogram.client.session.base import BaseSession
from aiogram.methods import EditMessageText, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods import EditMessageText, GetFile, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.types import Chat, Message, Update, User
from aiogram.types import Chat, File, Message, Update, User
BOT_TOKEN = "42:TEST-TOKEN"
@@ -19,6 +19,7 @@ class FakeSession(BaseSession):
super().__init__()
self.requests: list[TelegramMethod[Any]] = []
self._ids = count(1000)
self.files: dict[str, bytes] = {} # file_id -> content, for documents users "upload"
async def make_request(
self,
@@ -27,6 +28,10 @@ class FakeSession(BaseSession):
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
) -> TelegramType:
self.requests.append(method)
if isinstance(method, GetFile):
return File( # type: ignore[return-value]
file_id=method.file_id, file_unique_id=method.file_id, file_path=f"documents/{method.file_id}"
)
if isinstance(method, GetMe):
return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value]
if isinstance(method, SendPhoto):
@@ -54,9 +59,9 @@ class FakeSession(BaseSession):
timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
chunk_size: int = 65536,
raise_for_status: bool = True,
) -> AsyncGenerator[bytes, None]: # pragma: no cover - not used
raise NotImplementedError
yield b""
) -> AsyncGenerator[bytes, None]:
file_id = url.rsplit("/", 1)[-1]
yield self.files[file_id]
async def close(self) -> None:
pass
@@ -92,6 +97,18 @@ class TgClient:
message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}]
await self._feed({"message": message})
async def send_document(self, tg_id: int, name: str, content: bytes) -> None:
file_id = f"doc{next(self._message_ids)}"
self.session.files[file_id] = content
message = {
"message_id": next(self._message_ids),
"date": int(datetime.now(UTC).timestamp()),
"chat": {"id": tg_id, "type": "private"},
"from": self.user(tg_id),
"document": {"file_id": file_id, "file_unique_id": file_id, "file_name": name, "file_size": len(content)},
}
await self._feed({"message": message})
async def press(self, tg_id: int, data: str, message_text: str = "section") -> None:
callback = {
"id": str(next(self._update_ids)),