CI / ruff · mypy · pytest (pull_request) Successful in 4m3s
- infra/runner/tgit-runner: deploy/restart/remove/list по SSH forced command; аргументы не исполняются и не глобятся; архив — только файлы и папки, без «..», ≤ 20 МБ, есть main.py; health_url — только localhost - песочница: uid 10001, read-only, tmpfs noexec, cap-drop ALL, no-new-privileges, 128 МБ без swap, 0.25 CPU, 64 PID, --network none (сеть — только по egress-allow админа), лимит 10 сервисов, 2 образа - новая версия стартует рядом (-next) и заменяет старую, только став healthy; иначе удаляется, старая работает, exit 3 - бот: после push git archive изменённых services/<name>/ → runner; автору — «🚀 работает» или «⛔ не развёрнут» с логом, команде — алерт ⛔; удалённая папка → remove; «♻️ Перезапустить» в «Статусе» для админа (S-5) - examples/escape_attempts: все попытки выбраться — BLOCKED на живом Docker - install.sh, authorized_keys.example; CD бота обновляет runner на хосте Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
28 KiB
Python
641 lines
28 KiB
Python
"""«📝 Коммит»: browse the uploads repo tree and collect changes into a draft (SPEC C-1…C-16). Push — #9."""
|
|
|
|
import base64
|
|
import html
|
|
import io
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from aiogram import Bot, F, Router
|
|
from aiogram.enums import ParseMode
|
|
from aiogram.filters.callback_data import CallbackData
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.fsm.state import State, StatesGroup
|
|
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
|
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
|
|
from app.bot.menu import Nav, NavAction, free_input
|
|
from app.db.models import ChangeOp, User
|
|
from app.repo.drafts import DraftFull, DraftService, DraftView
|
|
from app.repo.git import Entry, GitError, UploadsRepo
|
|
from app.repo.publisher import Publisher, PushResult, PushStatus
|
|
from app.repo.validate import (
|
|
MAX_FILE_BYTES,
|
|
ValidationError,
|
|
check_content,
|
|
check_message,
|
|
check_name,
|
|
diff_stat,
|
|
is_protected,
|
|
join,
|
|
python_syntax,
|
|
strip_fence,
|
|
)
|
|
from app.runner.deployer import Deployer
|
|
from app.runner.deployer import Outcome as DeployOutcome
|
|
from app.texts import CommitTexts, 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"
|
|
DRAFT = "dr"
|
|
UNSTAGE = "x"
|
|
FORCE = "y"
|
|
FIX = "z"
|
|
MESSAGE = "m"
|
|
PUSH = "ps"
|
|
PUSH_GO = "pg"
|
|
PUSH_FORCE = "pf"
|
|
COMMIT = "cm"
|
|
|
|
|
|
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
|
|
|
|
|
|
class Flow(StatesGroup):
|
|
file_name = State()
|
|
dir_name = State()
|
|
content = State()
|
|
syntax = State()
|
|
message = State()
|
|
|
|
|
|
class Mark(StrEnum):
|
|
NONE = ""
|
|
NEW = "new"
|
|
MODIFIED = "mod"
|
|
DELETED = "del"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Row:
|
|
"""A line of the directory screen: a repo entry with the draft laid over it."""
|
|
|
|
path: str
|
|
kind: str
|
|
size: int
|
|
mark: Mark
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.path.rsplit("/", 1)[-1]
|
|
|
|
|
|
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 overlay(path: str, entries: list[Entry], draft: DraftView | None, virtual_dirs: list[str]) -> list[Row]:
|
|
"""Repo listing + what the draft adds, changes or deletes in this directory."""
|
|
changes = {c.path: c for c in draft.changes} if draft else {}
|
|
rows: dict[str, Row] = {}
|
|
for e in entries:
|
|
change = changes.get(e.path)
|
|
mark, size = Mark.NONE, e.size
|
|
if change is not None:
|
|
mark = Mark.DELETED if change.op is ChangeOp.DELETE else Mark.MODIFIED
|
|
if change.content is not None:
|
|
size = len(change.content) # show the size the file will have after the push
|
|
rows[e.path] = Row(e.path, e.kind, size, mark)
|
|
prefix = f"{path}/" if path else ""
|
|
for change in changes.values():
|
|
if change.op is not ChangeOp.ADD or not change.path.startswith(prefix):
|
|
continue
|
|
rest = change.path[len(prefix) :]
|
|
if "/" in rest: # a file deeper down: show its top folder here
|
|
sub = prefix + rest.split("/", 1)[0]
|
|
rows.setdefault(sub, Row(sub, "dir", 0, Mark.NEW))
|
|
else:
|
|
rows[change.path] = Row(change.path, "file", len(change.content or b""), Mark.NEW)
|
|
for vd in virtual_dirs:
|
|
if parent(vd) == path:
|
|
rows.setdefault(vd, Row(vd, "dir", 0, Mark.NEW))
|
|
return sorted(rows.values(), key=lambda r: (r.kind != "dir", r.name.lower()))
|
|
|
|
|
|
def mark_prefix(t: CommitTexts, mark: Mark) -> str:
|
|
return {Mark.NONE: "", Mark.NEW: t.mark_new, Mark.MODIFIED: t.mark_modified, Mark.DELETED: t.mark_deleted}[mark]
|
|
|
|
|
|
def render_dir(
|
|
texts: Texts, path: str, rows: list[Row], page: int, draft_count: int
|
|
) -> tuple[str, InlineKeyboardMarkup]:
|
|
t = texts.commit
|
|
pages = max(1, -(-len(rows) // PAGE_SIZE))
|
|
page = min(max(page, 0), pages - 1)
|
|
lines = [t.title.format(path=path)]
|
|
if not rows:
|
|
lines.append(t.empty)
|
|
elif pages > 1:
|
|
lines.append(t.page.format(page=page + 1, pages=pages))
|
|
|
|
kb = InlineKeyboardBuilder()
|
|
start = page * PAGE_SIZE
|
|
shown = rows[start : start + PAGE_SIZE]
|
|
for index, r in enumerate(shown, start=start):
|
|
base = (
|
|
t.dir_button.format(name=r.name)
|
|
if r.kind == "dir"
|
|
else t.file_button.format(name=r.name, size=human_size(r.size))
|
|
)
|
|
kb.button(text=mark_prefix(t, r.mark) + base, callback_data=CommitCb(a=Act.OPEN, i=index))
|
|
sizes = [1] * len(shown)
|
|
|
|
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 draft_count:
|
|
# the main action once something is staged — right in the folder, not two screens deep
|
|
kb.button(text=t.commit_button.format(count=draft_count), callback_data=CommitCb(a=Act.COMMIT))
|
|
kb.button(text=t.draft_button.format(count=draft_count), callback_data=CommitCb(a=Act.DRAFT))
|
|
sizes += [1, 1]
|
|
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 to_dir_keyboard(texts: Texts) -> InlineKeyboardMarkup:
|
|
kb = InlineKeyboardBuilder()
|
|
kb.button(text=texts.commit.to_dir, callback_data=CommitCb(a=Act.TO_DIR))
|
|
return kb.as_markup()
|
|
|
|
|
|
def build_router(
|
|
texts: Texts,
|
|
repo: UploadsRepo,
|
|
drafts: DraftService,
|
|
publisher: Publisher,
|
|
commit_url_template: str = "",
|
|
deployer: Deployer | None = None,
|
|
) -> Router:
|
|
router = Router(name="commit")
|
|
t = texts.commit
|
|
|
|
async def dir_screen(state: FSMContext, user_id: int, path: str, page: int) -> tuple[str, InlineKeyboardMarkup]:
|
|
data = await state.get_data()
|
|
virtual: list[str] = data.get("commit_virtual", [])
|
|
draft = await drafts.get(user_id)
|
|
try:
|
|
entries = await repo.list_dir(path)
|
|
except GitError:
|
|
# a folder that exists only in the draft (or a virtual one) has nothing in the repo yet
|
|
if path not in virtual and not (draft and any(c.path.startswith(f"{path}/") for c in draft.changes)):
|
|
raise
|
|
entries = []
|
|
rows = overlay(path, entries, draft, virtual)
|
|
await state.update_data(
|
|
commit_path=path,
|
|
commit_page=page,
|
|
commit_rows=[[r.path, r.kind, r.size, r.mark.value] for r in rows],
|
|
commit_file=None,
|
|
)
|
|
return render_dir(texts, path, rows, page, len(draft.changes) if draft else 0)
|
|
|
|
async def draft_screen(state: FSMContext, user_id: int) -> tuple[str, InlineKeyboardMarkup]:
|
|
draft = await drafts.get(user_id)
|
|
changes = draft.changes if draft else []
|
|
await state.update_data(commit_draft=[c.path for c in changes])
|
|
kb = InlineKeyboardBuilder()
|
|
if not changes:
|
|
text = t.draft_empty
|
|
else:
|
|
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
|
|
lines = [t.draft_title.format(count=len(changes)), ""]
|
|
lines += [f"{ops[c.op]} /{c.path}" for c in changes]
|
|
message = draft.message if draft else None
|
|
lines += ["", t.message_current.format(message=message) if message else t.message_missing]
|
|
text = "\n".join(lines)
|
|
for i, c in enumerate(changes):
|
|
kb.button(
|
|
text=t.unstage.format(name=c.path.rsplit("/", 1)[-1]), callback_data=CommitCb(a=Act.UNSTAGE, i=i)
|
|
)
|
|
if changes:
|
|
kb.button(text=t.message_button, callback_data=CommitCb(a=Act.MESSAGE))
|
|
if draft and draft.message:
|
|
kb.button(text=t.push_button, callback_data=CommitCb(a=Act.PUSH))
|
|
kb.button(text=t.to_dir, callback_data=CommitCb(a=Act.TO_DIR))
|
|
kb.adjust(1)
|
|
return text, kb.as_markup()
|
|
|
|
def confirm_screen(draft: DraftView) -> tuple[str, InlineKeyboardMarkup]:
|
|
builder = InlineKeyboardBuilder()
|
|
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
|
|
builder.button(text=t.edit_message, callback_data=CommitCb(a=Act.MESSAGE))
|
|
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.TO_DIR))
|
|
builder.adjust(1)
|
|
return t.push_confirm.format(summary=summary(draft), message=draft.message), builder.as_markup()
|
|
|
|
def summary(draft: DraftView) -> str:
|
|
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
|
|
return "\n".join(f"{ops[c.op]} /{c.path}" for c in draft.changes)
|
|
|
|
def deploy_report(msg: Message, sha: str) -> Callable[[DeployOutcome], Awaitable[None]]:
|
|
async def report(o: DeployOutcome) -> None:
|
|
name = html.escape(o.service)
|
|
if o.result.ok and o.removed:
|
|
text = t.removed.format(service=name)
|
|
elif o.result.ok:
|
|
text = t.deployed.format(service=name, sha=sha[:7])
|
|
else:
|
|
text = t.deploy_failed.format(service=name, reason=html.escape(o.result.summary))
|
|
if o.result.tail:
|
|
text += f"\n<pre>{html.escape(o.result.tail[-3000:])}</pre>"
|
|
await msg.answer(text, parse_mode=ParseMode.HTML)
|
|
|
|
return report
|
|
|
|
async def do_push(msg: Message, state: FSMContext, member: User, *, overwrite: bool) -> None:
|
|
draft = await drafts.get(member.tg_id)
|
|
if draft is None or not draft.changes or not draft.message:
|
|
text, kb = await draft_screen(state, member.tg_id)
|
|
await msg.edit_text(text, reply_markup=kb)
|
|
return
|
|
await msg.edit_text(t.pushing)
|
|
result: PushResult = await publisher.push(member, draft, draft.message, overwrite=overwrite)
|
|
match result.status:
|
|
case PushStatus.OK:
|
|
assert result.sha is not None
|
|
first_line = draft.message.splitlines()[0]
|
|
lines = [t.pushed.format(sha=result.sha[:7], message=first_line)]
|
|
if commit_url_template:
|
|
lines.append(commit_url_template.format(sha=result.sha))
|
|
lines.append(t.ci_pending)
|
|
if deployer is not None:
|
|
services = deployer.schedule(
|
|
result.sha, [c.path for c in draft.changes], deploy_report(msg, result.sha)
|
|
)
|
|
if services:
|
|
lines.append(t.deploying.format(services=", ".join(services)))
|
|
await msg.edit_text("\n".join(lines))
|
|
await publisher.record(member, result, draft.message, draft, msg.chat.id, msg.message_id)
|
|
await drafts.clear(member.tg_id)
|
|
await state.update_data(commit_virtual=[])
|
|
case PushStatus.CONFLICT:
|
|
builder = InlineKeyboardBuilder()
|
|
builder.button(text=t.overwrite, callback_data=CommitCb(a=Act.PUSH_FORCE))
|
|
builder.button(text=texts.menu.cancel, callback_data=CommitCb(a=Act.DRAFT))
|
|
paths = "\n".join(f"/{p}" for p in result.conflicts)
|
|
await msg.edit_text(t.conflict.format(paths=paths), reply_markup=builder.as_markup())
|
|
case PushStatus.EMPTY:
|
|
await drafts.clear(member.tg_id)
|
|
await msg.edit_text(t.push_empty)
|
|
case PushStatus.LIMIT:
|
|
await msg.edit_text(t.push_limit, reply_markup=to_dir_keyboard(texts))
|
|
case PushStatus.FAILED:
|
|
await msg.edit_text(t.push_failed, reply_markup=to_dir_keyboard(texts))
|
|
|
|
async def file_card(user_id: int, path: str, size: int, mark: Mark) -> str:
|
|
card = t.file_card.format(path=path, size=human_size(size))
|
|
if mark is Mark.NEW:
|
|
return f"{card}\n{t.in_draft_new}"
|
|
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
|
|
)
|
|
note = {Mark.MODIFIED: f"\n{t.in_draft_modified}", Mark.DELETED: f"\n{t.in_draft_deleted}"}.get(mark, "")
|
|
return f"{card}\n{tail}{note}"
|
|
|
|
async def current_content(user_id: int, path: str) -> bytes | None:
|
|
"""What the member sees as the file now: the draft version if there is one."""
|
|
draft = await drafts.get(user_id)
|
|
change = draft.change_for(path) if draft else None
|
|
if change is not None:
|
|
return change.content # None for a deleted file
|
|
return await repo.read(path)
|
|
|
|
async def send_file(msg: Message, user_id: int, path: str) -> None:
|
|
content = await current_content(user_id, path)
|
|
if content is None:
|
|
return
|
|
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, path.rsplit("/", 1)[-1]), caption=t.sent_as_file)
|
|
|
|
async def stage(message: Message, state: FSMContext, member: User, path: str, op: ChangeOp, content: bytes) -> None:
|
|
old = await current_content(member.tg_id, path) if op is ChangeOp.MODIFY else None
|
|
try:
|
|
await drafts.put(member.tg_id, await repo.head(), path, op, content)
|
|
except DraftFull:
|
|
await message.answer(t.draft_full)
|
|
return
|
|
if op is ChangeOp.ADD:
|
|
note = t.added.format(path=path)
|
|
else:
|
|
added, removed = diff_stat((old or b"").decode("utf-8", errors="replace"), content.decode("utf-8"))
|
|
note = t.modified.format(path=path, added=added, removed=removed)
|
|
data = await state.get_data()
|
|
# a virtual folder becomes real as soon as a file lands in it
|
|
virtual = [v for v in data.get("commit_virtual", []) if not path.startswith(f"{v}/")]
|
|
await state.set_state(None)
|
|
await state.update_data(commit_virtual=virtual, commit_pending=None)
|
|
text, kb = await dir_screen(state, member.tg_id, data.get("commit_path", ""), 0)
|
|
await message.answer(f"{note}\n\n{text}", reply_markup=kb)
|
|
|
|
# ---- entry point -------------------------------------------------------------------------------------------
|
|
|
|
@router.message(F.text == texts.menu.commit)
|
|
async def open_commit(message: Message, state: FSMContext, member: User) -> None:
|
|
await state.clear()
|
|
try:
|
|
await repo.refresh()
|
|
text, kb = await dir_screen(state, member.tg_id, "", 0)
|
|
except GitError:
|
|
log.exception("uploads repo unavailable")
|
|
await message.answer(t.unavailable)
|
|
return
|
|
await message.answer(text, reply_markup=kb)
|
|
|
|
# ---- buttons -----------------------------------------------------------------------------------------------
|
|
|
|
@router.callback_query(CommitCb.filter())
|
|
async def on_action(query: CallbackQuery, callback_data: CommitCb, state: FSMContext, member: User) -> 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", "")
|
|
rows: list[list[Any]] = data.get("commit_rows", [])
|
|
uid = member.tg_id
|
|
try:
|
|
match callback_data.a:
|
|
case Act.OPEN:
|
|
if not 0 <= callback_data.i < len(rows):
|
|
await query.answer() # stale button from an older screen
|
|
return
|
|
target, kind, size, mark = rows[callback_data.i]
|
|
if kind == "dir":
|
|
await repo.refresh()
|
|
text, kb = await dir_screen(state, uid, target, 0)
|
|
await msg.edit_text(text, reply_markup=kb)
|
|
else:
|
|
await state.update_data(commit_file=target)
|
|
await msg.edit_text(
|
|
await file_card(uid, target, size, Mark(mark)), reply_markup=file_keyboard(texts)
|
|
)
|
|
case Act.UP:
|
|
text, kb = await dir_screen(state, uid, parent(path), 0)
|
|
await msg.edit_text(text, reply_markup=kb)
|
|
case Act.PAGE:
|
|
real = [Row(p, k, s, Mark(m)) for p, k, s, m in rows]
|
|
draft = await drafts.get(uid)
|
|
await state.update_data(commit_page=callback_data.i)
|
|
text, kb = render_dir(texts, path, real, callback_data.i, len(draft.changes) if draft else 0)
|
|
await msg.edit_text(text, reply_markup=kb)
|
|
case Act.TO_DIR:
|
|
await state.set_state(None)
|
|
text, kb = await dir_screen(state, uid, path, data.get("commit_page", 0))
|
|
await msg.edit_text(text, reply_markup=kb)
|
|
case Act.VIEW:
|
|
if data.get("commit_file"):
|
|
await send_file(msg, uid, data["commit_file"])
|
|
case Act.NEW_FILE:
|
|
await state.set_state(Flow.file_name)
|
|
await msg.edit_text(t.ask_file_name.format(dir=path), reply_markup=to_dir_keyboard(texts))
|
|
case Act.NEW_DIR:
|
|
await state.set_state(Flow.dir_name)
|
|
await msg.edit_text(t.ask_dir_name.format(dir=path), reply_markup=to_dir_keyboard(texts))
|
|
case Act.REPLACE:
|
|
target = data.get("commit_file")
|
|
if not target:
|
|
await query.answer()
|
|
return
|
|
if is_protected(target):
|
|
await query.answer(t.protected, show_alert=True)
|
|
return
|
|
await state.set_state(Flow.content)
|
|
await state.update_data(commit_target=target, commit_op=ChangeOp.MODIFY.value)
|
|
await msg.edit_text(t.ask_content.format(path=target), reply_markup=to_dir_keyboard(texts))
|
|
case Act.DELETE:
|
|
target = data.get("commit_file")
|
|
if not target:
|
|
await query.answer()
|
|
return
|
|
if is_protected(target):
|
|
await query.answer(t.protected, show_alert=True)
|
|
return
|
|
await drafts.put(uid, await repo.head(), target, ChangeOp.DELETE, None)
|
|
text, kb = await dir_screen(state, uid, path, data.get("commit_page", 0))
|
|
await msg.edit_text(f"{t.deleted.format(path=target)}\n\n{text}", reply_markup=kb)
|
|
case Act.DRAFT:
|
|
text, kb = await draft_screen(state, uid)
|
|
await msg.edit_text(text, reply_markup=kb)
|
|
case Act.UNSTAGE:
|
|
paths: list[str] = data.get("commit_draft", [])
|
|
if 0 <= callback_data.i < len(paths):
|
|
await drafts.drop(uid, paths[callback_data.i])
|
|
text, kb = await draft_screen(state, uid)
|
|
await msg.edit_text(text, reply_markup=kb)
|
|
case Act.FORCE:
|
|
pending = data.get("commit_pending")
|
|
if pending is None or await state.get_state() != Flow.syntax.state:
|
|
await query.answer()
|
|
return
|
|
await query.answer()
|
|
await stage(
|
|
msg,
|
|
state,
|
|
member,
|
|
data["commit_target"],
|
|
ChangeOp(data["commit_op"]),
|
|
base64.b64decode(pending),
|
|
)
|
|
return
|
|
case Act.MESSAGE:
|
|
await state.set_state(Flow.message)
|
|
await state.update_data(commit_then_push=False)
|
|
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
|
|
case Act.PUSH:
|
|
draft = await drafts.get(uid)
|
|
if draft is None or not draft.changes or not draft.message:
|
|
await query.answer()
|
|
return
|
|
confirm, markup = confirm_screen(draft)
|
|
await msg.edit_text(confirm, reply_markup=markup)
|
|
case Act.COMMIT:
|
|
draft = await drafts.get(uid)
|
|
if draft is None or not draft.changes:
|
|
await query.answer()
|
|
return
|
|
if draft.message:
|
|
confirm, markup = confirm_screen(draft)
|
|
await msg.edit_text(confirm, reply_markup=markup)
|
|
else:
|
|
# ask for the message, then go straight to the push confirmation
|
|
await state.set_state(Flow.message)
|
|
await state.update_data(commit_then_push=True)
|
|
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
|
|
case Act.PUSH_GO | Act.PUSH_FORCE:
|
|
await query.answer()
|
|
await do_push(msg, state, member, overwrite=callback_data.a is Act.PUSH_FORCE)
|
|
return
|
|
case Act.FIX:
|
|
await state.set_state(Flow.content)
|
|
await state.update_data(commit_pending=None)
|
|
await msg.edit_text(
|
|
t.ask_content.format(path=data.get("commit_target", "")), reply_markup=to_dir_keyboard(texts)
|
|
)
|
|
except GitError:
|
|
log.exception("uploads repo unavailable")
|
|
await query.answer(t.unavailable, show_alert=True)
|
|
return
|
|
except DraftFull:
|
|
await query.answer(t.draft_full, show_alert=True)
|
|
return
|
|
await query.answer()
|
|
|
|
# ---- text input --------------------------------------------------------------------------------------------
|
|
|
|
@router.message(Flow.file_name, F.text, free_input(texts))
|
|
async def got_file_name(message: Message, state: FSMContext, member: User) -> None:
|
|
data = await state.get_data()
|
|
directory = data.get("commit_path", "")
|
|
try:
|
|
name = check_name(message.text or "")
|
|
except ValidationError:
|
|
await message.answer(t.bad_name)
|
|
return
|
|
path = join(directory, name)
|
|
if is_protected(path):
|
|
await message.answer(t.protected)
|
|
return
|
|
taken = {row[0] for row in data.get("commit_rows", [])}
|
|
if path in taken:
|
|
await message.answer(t.exists)
|
|
return
|
|
await state.set_state(Flow.content)
|
|
await state.update_data(commit_target=path, commit_op=ChangeOp.ADD.value)
|
|
await message.answer(t.ask_content.format(path=path), reply_markup=to_dir_keyboard(texts))
|
|
|
|
@router.message(Flow.dir_name, F.text, free_input(texts))
|
|
async def got_dir_name(message: Message, state: FSMContext, member: User) -> None:
|
|
data = await state.get_data()
|
|
directory = data.get("commit_path", "")
|
|
try:
|
|
name = check_name(message.text or "")
|
|
except ValidationError:
|
|
await message.answer(t.bad_name)
|
|
return
|
|
path = join(directory, name)
|
|
if is_protected(path + "/x"):
|
|
await message.answer(t.protected)
|
|
return
|
|
if path in {row[0] for row in data.get("commit_rows", [])}:
|
|
await message.answer(t.exists)
|
|
return
|
|
await state.set_state(None)
|
|
await state.update_data(commit_virtual=[*data.get("commit_virtual", []), path])
|
|
text, kb = await dir_screen(state, member.tg_id, path, 0)
|
|
await message.answer(text, reply_markup=kb)
|
|
|
|
@router.message(Flow.message, F.text, free_input(texts))
|
|
async def got_message(message: Message, state: FSMContext, member: User) -> None:
|
|
try:
|
|
text = check_message(message.text or "")
|
|
except ValidationError:
|
|
await message.answer(t.bad_message)
|
|
return
|
|
await drafts.set_message(member.tg_id, text)
|
|
await state.set_state(None)
|
|
draft = await drafts.get(member.tg_id)
|
|
if (await state.get_data()).get("commit_then_push") and draft is not None and draft.changes:
|
|
await state.update_data(commit_then_push=False)
|
|
screen, kb = confirm_screen(draft)
|
|
else:
|
|
screen, kb = await draft_screen(state, member.tg_id)
|
|
await message.answer(screen, reply_markup=kb)
|
|
|
|
@router.message(Flow.content, F.text | F.document, free_input(texts))
|
|
async def got_content(message: Message, state: FSMContext, member: User, bot: Bot) -> None:
|
|
data = await state.get_data()
|
|
target: str = data["commit_target"]
|
|
if message.document is not None:
|
|
if (message.document.file_size or 0) > MAX_FILE_BYTES:
|
|
await message.answer(t.too_big)
|
|
return
|
|
buf = io.BytesIO()
|
|
await bot.download(message.document, destination=buf)
|
|
raw = buf.getvalue()
|
|
else:
|
|
raw = strip_fence(message.text or "").encode("utf-8")
|
|
try:
|
|
text = check_content(raw)
|
|
except ValidationError as e:
|
|
await message.answer({"too_big": t.too_big, "binary": t.binary, "encoding": t.encoding}[str(e)])
|
|
return
|
|
issue = python_syntax(target, text)
|
|
if issue is not None:
|
|
await state.set_state(Flow.syntax)
|
|
await state.update_data(commit_pending=base64.b64encode(raw).decode())
|
|
kb = InlineKeyboardBuilder()
|
|
kb.button(text=t.fix, callback_data=CommitCb(a=Act.FIX))
|
|
kb.button(text=t.force, callback_data=CommitCb(a=Act.FORCE))
|
|
await message.answer(t.syntax.format(line=issue.line, message=issue.message), reply_markup=kb.as_markup())
|
|
return
|
|
await stage(message, state, member, target, ChangeOp(data["commit_op"]), raw)
|
|
|
|
return router
|