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:
@@ -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
|
||||
Reference in New Issue
Block a user