feat(bot): Коммит — навигация по дереву репозитория загрузок

- UploadsRepo: локальный клон, fetch не чаще fetch_interval, ls-tree -z (пробелы/кириллица),
  cat-file, последний коммит по пути; git без shell, ssh с -F /dev/null (без конфига хоста)
- «📝 Коммит»: папки/файлы с размерами, ⬆️ Вверх, страницы по 20, карточка файла
  с последним коммитом, «👁 Показать» (<pre> или документом); в кнопках только индексы
- ключ для контейнера: GIT_SSH_KEY_B64 → временный файл 0600 (смонтированный файл хоста
  контейнерному uid не прочитать); volume /data для клона
- тесты на настоящем bare-репозитории во временной папке

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:10:30 +03:00
co-authored by Claude Opus 5.5
parent 219bebd5a2
commit dbb17bd774
17 changed files with 692 additions and 12 deletions
+207
View File
@@ -0,0 +1,207 @@
"""«📝 Коммит»: browsing the uploads repo tree with buttons (SPEC C-1…C-4). Changes and push — #26, #9."""
import html
import logging
from enum import StrEnum
from typing import Any
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.filters.callback_data import CallbackData
from aiogram.fsm.context import FSMContext
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from app.bot.menu import Nav, NavAction
from app.repo.git import Entry, GitError, UploadsRepo
from app.texts import Texts
log = logging.getLogger(__name__)
PAGE_SIZE = 20
INLINE_VIEW_LIMIT = 3500 # chars; Telegram caps a message at 4096 and <pre> markup takes some
class Act(StrEnum):
OPEN = "o"
UP = "u"
PAGE = "p"
VIEW = "v"
TO_DIR = "b"
REPLACE = "r"
DELETE = "d"
NEW_FILE = "f"
NEW_DIR = "n"
class CommitCb(CallbackData, prefix="c"):
# Only an action and a small index: paths never go into callback_data (64-byte limit, ARCHITECTURE §2.1)
a: Act
i: int = 0
def human_size(n: int) -> str:
size = float(n)
for unit in ("Б", "КБ", "МБ"):
if size < 1024 or unit == "МБ":
return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}"
size /= 1024
raise AssertionError("unreachable")
def parent(path: str) -> str:
return path.rsplit("/", 1)[0] if "/" in path else ""
def render_dir(texts: Texts, path: str, entries: list[Entry], page: int) -> tuple[str, InlineKeyboardMarkup]:
t = texts.commit
pages = max(1, -(-len(entries) // PAGE_SIZE))
page = min(max(page, 0), pages - 1)
lines = [t.title.format(path=path)]
if not entries:
lines.append(t.empty)
elif pages > 1:
lines.append(t.page.format(page=page + 1, pages=pages))
kb = InlineKeyboardBuilder()
start = page * PAGE_SIZE
for index, e in enumerate(entries[start : start + PAGE_SIZE], start=start):
label = (
t.dir_button.format(name=e.name)
if e.kind == "dir"
else t.file_button.format(name=e.name, size=human_size(e.size))
)
kb.button(text=label, callback_data=CommitCb(a=Act.OPEN, i=index))
sizes = [1] * min(PAGE_SIZE, max(0, len(entries) - start))
paging: list[tuple[str, CommitCb]] = []
if page > 0:
paging.append((t.prev, CommitCb(a=Act.PAGE, i=page - 1)))
if page < pages - 1:
paging.append((t.next, CommitCb(a=Act.PAGE, i=page + 1)))
for text, cb in paging:
kb.button(text=text, callback_data=cb)
if paging:
sizes.append(len(paging))
kb.button(text=t.new_file, callback_data=CommitCb(a=Act.NEW_FILE))
kb.button(text=t.new_dir, callback_data=CommitCb(a=Act.NEW_DIR))
sizes.append(2)
if path:
kb.button(text=t.up, callback_data=CommitCb(a=Act.UP))
sizes.append(1)
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.button(text=texts.menu.cancel, callback_data=Nav(action=NavAction.CANCEL))
sizes.append(2)
kb.adjust(*sizes)
return "\n".join(lines), kb.as_markup()
def file_keyboard(texts: Texts) -> InlineKeyboardMarkup:
t = texts.commit
kb = InlineKeyboardBuilder()
kb.button(text=t.view, callback_data=CommitCb(a=Act.VIEW))
kb.button(text=t.replace, callback_data=CommitCb(a=Act.REPLACE))
kb.button(text=t.delete, callback_data=CommitCb(a=Act.DELETE))
kb.button(text=t.to_dir, callback_data=CommitCb(a=Act.TO_DIR))
kb.adjust(1, 2, 1)
return kb.as_markup()
def build_router(texts: Texts, repo: UploadsRepo) -> Router:
router = Router(name="commit")
t = texts.commit
async def show_dir(message: Message, state: FSMContext, path: str, page: int, *, edit: bool) -> None:
entries = await repo.list_dir(path)
await state.update_data(
commit_path=path,
commit_page=page,
commit_entries=[[e.path, e.kind, e.size] for e in entries],
commit_file=None,
)
text, kb = render_dir(texts, path, entries, page)
if edit:
await message.edit_text(text, reply_markup=kb)
else:
await message.answer(text, reply_markup=kb)
@router.message(F.text == texts.menu.commit)
async def open_commit(message: Message, state: FSMContext) -> None:
await state.clear()
try:
await repo.refresh()
await show_dir(message, state, "", 0, edit=False)
except GitError:
log.exception("uploads repo unavailable")
await message.answer(t.unavailable)
@router.callback_query(CommitCb.filter())
async def on_action(query: CallbackQuery, callback_data: CommitCb, state: FSMContext) -> None:
if not isinstance(query.message, Message):
await query.answer()
return
msg = query.message
data: dict[str, Any] = await state.get_data()
path: str = data.get("commit_path", "")
entries: list[list[Any]] = data.get("commit_entries", [])
try:
match callback_data.a:
case Act.OPEN:
if not 0 <= callback_data.i < len(entries):
await query.answer() # stale button from an older screen
return
target, kind, size = entries[callback_data.i]
if kind == "dir":
await repo.refresh()
await show_dir(msg, state, target, 0, edit=True)
else:
await state.update_data(commit_file=target)
await msg.edit_text(await file_card(target, size), reply_markup=file_keyboard(texts))
case Act.UP:
await show_dir(msg, state, parent(path), 0, edit=True)
case Act.PAGE:
await state.update_data(commit_page=callback_data.i)
real = [Entry(p, k, s) for p, k, s in entries]
text, kb = render_dir(texts, path, real, callback_data.i)
await msg.edit_text(text, reply_markup=kb)
case Act.TO_DIR:
await show_dir(msg, state, path, data.get("commit_page", 0), edit=True)
case Act.VIEW:
file_path = data.get("commit_file")
if file_path:
await send_file(msg, file_path)
case Act.REPLACE | Act.DELETE | Act.NEW_FILE | Act.NEW_DIR:
await query.answer(t.soon, show_alert=True)
return
except GitError:
log.exception("uploads repo unavailable")
await query.answer(t.unavailable, show_alert=True)
return
await query.answer()
async def file_card(path: str, size: int) -> str:
card = t.file_card.format(path=path, size=human_size(size))
info = await repo.last_commit(path)
tail = (
t.last_commit.format(
sha=info.sha[:7], author=info.author, date=info.date.strftime("%d.%m.%Y %H:%M"), subject=info.subject
)
if info
else t.no_commits
)
return f"{card}\n{tail}"
async def send_file(msg: Message, path: str) -> None:
content = await repo.read(path)
name = path.rsplit("/", 1)[-1]
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = None
if text is not None and "\0" not in text and len(text) <= INLINE_VIEW_LIMIT:
await msg.answer(f"<b>/{html.escape(path)}</b>\n<pre>{html.escape(text)}</pre>", parse_mode=ParseMode.HTML)
else:
await msg.answer_document(BufferedInputFile(content, name), caption=t.sent_as_file)
return router