
На своем тг-канале я предлагаю подписчикам выбор, какую бредовую идею запилить следующей. На этот раз подписчики выбрали новый челлендж: сделать Git в Telegram. Чтобы можно было через бота инитить проекты, пушить файлы, коммитить — и всё это в публичном канале с тредами.
С практической точки зрения этот проект нахуй не нужен. Есть гитхаб, есть гитлаб, есть куча нормальных инструментов. Но как эксперимент — почему бы и нет? Чисто посмотреть, можно ли заставить Telegram работать как VCS.
Я тогда подумал: «Ну, бот на aiogram, база данных, пара команд — делов то))»
Словари, датаклассы и прочая е*атория
Когда я только начинал, первая мысль была: «Положу всё в JSON, на кой мне база данных?» Ну серьёзно, проектов мало, пользователей немного, файлы текстовые че заморачитватся.
Подергал JSON туда-сюда пару дней и понял: не варик.
Во-первых, конкурентный доступ. Два юзера одновременно коммитят — один из них перезаписывает файл другого. Во-вторых, целостность данных. Если бот упал в середине записи — JSON остаётся в невалидном состоянии. В-третьих, версионность. Хранить историю из��енений в JSON — это просто перенести проблему из кода в структуру файла.
Короче, JSON — для конфигов, а не для данных, которые меняются каждую секунду.
Выбор пал на SQLite.
Почему:
Не надо поднимать отдельный сервер
Целостность данных на уровне движка (транзакции, foreign keys, rollback)
Всё в одном файле — скопировал и унёс
Но просто накидать таблиц мало. Надо было понять, какие сущности в виде гномика будут жить в базе.
Сущности:
users — кто юзает. Просто telegram_id, username и current_project_id.
projects — проекты. Привязка к владельцу (owner_id), название, thread_id (это для канала, там каждый проект живёт в своём треде).
files — файлы внутри проекта. filename, current_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('<', '<') .replace('>', '>')) 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и смотришьНикаких
AbstractMinerShieldEventProcessor,BaseGitGramManager,InterfaceProviderFactory
Код должен быть тупым. Чем тупее — тем проще его читать и отлаживать.
На момент написания этой статьи, GitGram принимает файлы, режет их на куски если надо, пихает в канал с подсветкой, коммиты ходят, ветки переключаются, диффы показываются. Всё это в тредах, каждый проект отдельно.
В планах ещё куча всего. Хочу докрутить коллаборацию, чтобы несколько человек могли пилить один проект. Приватные репозитории сделать. А там может и до безумных идей дойдём — типа кодспейса прямо в боте, чтобы код редактировать можно было не выходя из Telegram. Посмотрим, насколько хватит моей шизы! :-)
Да, на практике GitGram нахуй не нужен. Есть GitHub, GitLab, куча нормальных инструментов. Но сама задумка — Git в Telegram — это же так прикольно. Просто посмотреть, можно ли такое вообще запилить.
Если хотите глянуть как оно работает или поучаствовать в разработке (а точнее посмотреть как я воюю с очередным багом) — вот ссылки:
Канал-репозиторий: @Git_Gram — сюда падают все файлы, коммиты и диффы.
Бот: @Git_GramBOT
Чат для хардкорных, где публикуется вся сырая разработка, факапы и обсуждения: @sandbox_hardcore
Там всё без цензуры.
