На своем тг-канале я предлагаю подписчикам выбор, какую бредовую идею запилить следующей. На этот раз подписчики выбрали новый челлендж: сделать Git в Telegram. Чтобы можно было через бота инитить проекты, пушить файлы, коммитить — и всё это в публичном канале с тредами.

С практической точки зрения этот проект нахуй не нужен. Есть гитхаб, есть гитлаб, есть куча нормальных инструментов. Но как эксперимент — почему бы и нет? Чисто посмотреть, можно ли заставить Telegram работать как VCS.

Я тогда подумал: «Ну, бот на aiogram, база данных, пара команд — делов то))»

Словари, датаклассы и прочая е*атория

Когда я только начинал, первая мысль была: «Положу всё в JSON, на кой мне база данных?» Ну серьёзно, проектов мало, пользователей немного, файлы текстовые че заморачитватся.

Подергал JSON туда-сюда пару дней и понял: не варик.

Во-первых, конкурентный доступ. Два юзера одновременно коммитят — один из них перезаписывает файл другого. Во-вторых, целостность данных. Если бот упал в середине записи — JSON остаётся в невалидном состоянии. В-третьих, версионность. Хранить историю изменений в JSON — это просто перенести проблему из кода в структуру файла.

Короче, JSON — для конфигов, а не для данных, которые меняются каждую секунду.

Выбор пал на SQLite.

Почему:

  • Не надо поднимать отдельный сервер

  • Целостность данных на уровне движка (транзакции, foreign keys, rollback)

  • Всё в одном файле — скопировал и унёс

Но просто накидать таблиц мало. Надо было понять, какие сущности в виде гномика будут жить в базе.

Сущности:

users — кто юзает. Просто telegram_idusername и current_project_id.

projects — проекты. Привязка к владельцу (owner_id), название, thread_id (это для канала, там каждый проект живёт в своём треде).

files — файлы внутри проекта. filenamecurrent_version, флаг modified (помечает, что файл изменился и готов к коммиту).

file_versions — тут самое мясо. Каждая версия файла с полным содержимым. Привязана к file_id и опционально к commit_id.

commits — коммиты. Сообщение, время, ссылка на проект.

commit_files — связка коммитов с версиями файлов. Many-to-many, потому что один коммит может включать несколько файлов, и одна версия файла может быть в нескольких коммитах (если ветки, например).

Почему именно так:

Я хотел, чтобы можно было откатиться к любой версии любого файла. Не просто последняя версия, а прям всё хранить. Да, база распухнет, но текстовые файлы — это не гигабайты видео.

Кроме того, наличие file_versions и commit_files позволяет делать diff между версиями, смотреть историю изменений, понимать, что когда и кем было изменено.

Датаклассы:

В коде я не хотел таскать кортежи из SQL-запросов. Это нечитаемо и ошибки легко пропустить. Поэтому завёл датаклассы под каждую сущность:

@dataclass
class Project:
    id: int
    name: str
    owner_id: int
    thread_id: Optional[int] = None
    created_at: Optional[datetime] = None

Я возненавидел маркДАУНА!!!

Казалось бы, что может быть проще: взял код, обернул в тройные апострофы, кинул в Telegram. Telegram сам подсветит синтаксис, если указать язык. Красота.

В теории.

На практике Telegram использует свой диалект Markdown, где куча служебных символов: _*[]()~>#+-=|{}.! — и их надо экранировать обратным слешем, если они встречаются в обычном тексте.

В коде эти символы встречаются на каждом шагу. Особенно в Python, Go, JS и других языках, где точки, подчеркивания и звездочки — часть синтаксиса.

Первая попытка: заэкранировать всё подряд:

def escape_code_content(content: str) -> str:
    special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
    for char in special_chars:
        content = content.replace(char, f'\\{char}')
    return content

Результат: код превращается в кашу. Вместо def init получается def \_\_init\_\_, который уже не запустить, но в канале выглядит как говно.

Вторая попытка: не экранировать вообще

Telegram шлёт нахуй с ошибкой "can't parse entities".

Третья попытка: экранировать только то, что реально ломает разметку

Выяснилось, что порядок важен:

# Сначала экранируем точки и подчеркивания
content = content.replace('.', '\\.')
content = content.replace('_', '\\_')
# Потом экранируем обратную косую черту (иначе слеши от первого экранирования размножатся)
content = content.replace('\\', '\\\\')

Но и это не панацея. В коде могут быть последовательности типа \*, которые после экранирования превращаются в \\* — и Telegram снова недоволен.

Четвертая попытка: разбивать на части

Для больших файлов я решил делать превью. Обрезаю первые 50 строк, экранирую их, отправляю как код, а полную версию прикрепляю файлом.

if len(content) > 8000:
    lines = content.split('\n')
    preview_lines = lines[:50]
    preview = '\n'.join(preview_lines)
    preview = escape_code_content(preview)
    # ... отправка превью + файла

И тут начался ад. Telegram находил ошибки в тех частях кода, которых в превью вообще не было. Оказывается, он всё равно парсил полный код, даже если отправлялась только его часть.

Пятая попытка: HTML

В какой-то момент я просто забил на Markdown. Telegram умеет принимать HTML. Да, он не такой красивый, но зато предсказуемый.

def safe_html(text: str) -> str:
    return (text.replace('&', '&')
            .replace('<', '&lt;')
            .replace('>', '&gt;'))

def format_code_html(filename: str, content: str) -> str:
    lang = get_language_from_filename(filename)
    content = safe_html(content)
    return f'<pre><code class="language-{lang}">{content}</code></pre>'

Всё. Никаких точек, подчеркиваний, обратных слешей. Просто экранируем три символа — и код летит как надо.
Права доступа работали изначально

Когда бот начал обрастать функциями, я задумался о безопасности. Чтобы никто не мог коммитить или удалять чужие файлы.

Начал писать проверки в каждую команду:

if project.owner_id != user_id:
    await message.answer("❌ Это не твой проект")
    return

Добавил в /commit. Потом думаю дай ка со второго акка потестю команды на чужих файлах. Так бот на каждую команду стал кричать мол файл не найден или укажите название проекта,а когда я указывал проект,чужой проект, бот говорил что такой проект тоже не найден! И тогда я понял.

ОНИ УЖЕ РАБОТАЮТ!!

С самого начала. Из коробки. Без единой строчки кода.

Как это вышло:

В таблице projects есть поле owner_id. При создании проекта я туда пишу telegram_id владельца. Это было настолько очевидно, что я даже не задумывался о безопасности — просто архитектура требовала знать, кому принадлежит проект.

Дальше — все запросы к БД фильтруются по этому полю:

def get_user_projects(self, telegram_id):
    return self.execute(
        "SELECT * FROM projects WHERE owner_id = ?", 
        (telegram_id,)
    )

Показать проекты — только свои. Найти файл — только в своих проектах. Выбрать проект — только из своих.

Никаких лишних проверок. Просто SQL-запросы, которые с самого начала учитывали владельца.

С тех пор я добавил проверку в /commit на всякий случай (удвоенная защита не помешает), но в остальных командах она не нужна. База данных сама отсекает чужое.


Команда команд)))
Когда я только начинал, казалось, что команд будет немного. Ну там /init/commit/log — классический набор. Кто будет юзать что-то большее?

Жизнь рассудила иначе.

Первая волна — базовые

/start — регистрация
/init — создать проект
/use — выбрать текущий проект
/list — список проектов
/ls — файлы в проекте
/commit — закоммитить
/log — история
/status — изменения

Вроде норм. Семь команд — не дохера.

Вторая волна — удаление

Понадобилось удалять файлы и проекты. Сделал:
/rm — удалить файл
/rmproject — удалить проект целиком

Но тут нюанс: удаление проекта — это страшно. Надо подтверждение. Появилась команда /rmproject_confirm.

Третья волна — игнор

Подписчики попросили .gitignore. Пришлось добавлять:
/ignore — добавить паттерн
/ignored — посмотреть что игнорится
/unignore — удалить из игнора (добавил позже, но в процессе понял что надо)

Четвертая волна — ветки

Git без веток — не git. Поехали:
/branch — создать ветку
/branches — список веток
/checkout — переключиться

Пятая волна — диффы и просмотр

/diff — сравнить версии
/cat — показать содержимое файла в чате (чтоб не качать)
/tree — структуру проекта захотелось (пока не сделал, но в планах)

Итого: команд уже под два десятка. И это не предел.

Когда я писал каждую новую команду, казалось: "Ну вот эта точно последняя". Через день появлялась следующая. Потому что реальный git — он большой. И если хочешь сделать его подобие в Telegram, придётся тащить почти всё.

Я не стал группировать команды в меню (в телеграме это не очень работает). Просто сделал список в /help и так же /help <команда>

Единственное, что спасает — aiogram позволяет делать удобный парсинг аргументов. Почти везде одна схема: команда + опционально имя. Так что плодить новые обработчики быстро.

Примитивно не значит плохо..

В моём коде нет абстрактных базовых классов. Совсем.

Почему? Потому что они нужны только когда у тебя есть минимум две разные реализации одного и того же. В GitGram всё проще: есть один способ работать с базой данных, один способ шифровать, один способ парсить .gitignore.

Если завтра появится вторая реализация — тогда и буду делать интерфейс. А пока это просто оверхед.

В некоторых проектах любят на каждый чих накидывать абстракции. Типа «а вдруг потом пригодится». Спойлер: не пригождается. Код превращается в матрёшку, где чтобы понять, что реально происходит, нужно открыть пять файлов и собрать в голове цепочку наследований.

Я так не хочу.

В GitGram:

  • Хочешь понять, как работает add_file — идёшь в database.py и читаешь 10 строк кода

  • Хочешь увидеть обработчик /commit — открываешь bot.py и смотришь

  • Никаких AbstractMinerShieldEventProcessorBaseGitGramManagerInterfaceProviderFactory

Код должен быть тупым. Чем тупее — тем проще его читать и отлаживать.

На момент написания этой статьи, GitGram принимает файлы, режет их на куски если надо, пихает в канал с подсветкой, коммиты ходят, ветки переключаются, диффы показываются. Всё это в тредах, каждый проект отдельно.

В планах ещё куча всего. Хочу докрутить коллаборацию, чтобы несколько человек могли пилить один проект. Приватные репозитории сделать. А там может и до безумных идей дойдём — типа кодспейса прямо в боте, чтобы код редактировать можно было не выходя из Telegram. Посмотрим, насколько хватит моей шизы! :-)

Да, на практике GitGram нахуй не нужен. Есть GitHub, GitLab, куча нормальных инструментов. Но сама задумка — Git в Telegram — это же так прикольно. Просто посмотреть, можно ли такое вообще запилить.

Если хотите глянуть как оно работает или поучаствовать в разработке (а точнее посмотреть как я воюю с очередным багом) — вот ссылки:

  • Канал-репозиторий: @Git_Gram — сюда падают все файлы, коммиты и диффы.

  • Бот: @Git_GramBOT

  • Чат для хардкорных, где публикуется вся сырая разработка, факапы и обсуждения: @sandbox_hardcore

Там всё без цензуры.