tgsum: выгрузка Telegram в Markdown, который не стыдно отдать ИИ

ИнструментыTypeScriptИИ

Человеку без технического бэкграунда нужно было понять, что происходило в рабочем чате за несколько месяцев: кто чем недоволен, где всё встало, что осталось невыясненным. Telegram Desktop умеет выгрузить переписку аккаунта в JSON — на практике это файл до пары гигабайт: сотни чатов, годы истории, служебные сообщения, реакции. Прочитать глазами нельзя, вставить в чат с ИИ — тем более.

Задача пришла как «сделай саммари переписки». Первое, что я сделал, — разделил её надвое. Достать нужные сообщения из выгрузки — инженерная работа с проверяемым результатом: либо сообщения на месте, либо нет. Понять, кто кого блокирует, — интерпретация; её нормально делает чат с ИИ и плохо делает любой код, который я напишу.

tgsum делает только первую половину.

Почему внутри нет ни одной модели

Соблазн был: добавить вызов API и отдавать сразу готовое саммари. Не стал по трём причинам.

Первая — саммари нужен охват, а не релевантность. Стандартный ответ на «текста больше, чем влезает в модель» — эмбеддинги и поиск по кускам. Но это инструмент поиска подходящего, а сводке нужно, чтобы ничего не выпало. Такой поиск будет молча выбрасывать куски и смещать картину, причём результат будет выглядеть прилично — это худший вид ошибки.

Вторая — ключи. Инструмент с ключом внутри означает чужой счёт, лимиты, ротацию и разговор «а куда ушли данные». Без сети объяснение помещается в строчку: файл лежит на диске, программа читает его и пишет рядом .md. Проверить это можно, просто отключив интернет.

Третья — контроль. Промежуточный файл можно открыть и прочитать глазами до того, как что-то куда-то отправлено. Модель в середине конвейера эту возможность забирает.

Что он делает с выгрузкой

Два потоковых прохода по файлу. JSON.parse тут невозможен в принципе: Node не соберёт строку больше примерно 512 МБ, а если бы и собрал — не хватит памяти. Поэтому потоковый разбор:

const pipeline = chain([
  createReadStream(path),
  parser(),
  pick({ filter: 'chats.list' }),
  streamArray(),
])

Первый проход строит лёгкий индекс: какие есть чаты и топики, сколько в них сообщений, за какой период. Второй читает файл заново и достаёт только отмеченное. Диск читается дважды — это осознанный размен: простая логика вместо экономии времени.

Дальше форматирование. Формат подбирался под качество будущего саммари, а не под минимум токенов:

# Чат: Команда / Топик: Релизы
# Период: 2026-06-18 — 2026-06-20 | сообщений: 142 | участники: Алиса, Боб, Вера

## 2026-06-20
[14:02] Алиса: когда катим релиз?
[14:03] Боб: давай в 15:00, сначала смёржь PR #210
[14:05] Вера ↳ Боб «смёржь PR #210»: смёржила
[14:06] Боб: [photo] логи деплоя

Шапка даёт модели контекст сразу: что за чат, за какой период, кто участвовал. День вынесен в заголовок. Реплай разворачивается в ↳ Имя «начало цитаты» — без этого «согласен» не к чему привязать. Служебные сообщения (вошёл, закрепил, звонок) и реакции вырезаются, медиа сворачивается в маркеры вроде [voice 0:42]. Подписи к фото — обычный текст сообщения, они сохраняются.

Слишком большой чат режется на части, каждая со своей шапкой и с заголовком дня наверху. Разбивка без потерь: ни одно сообщение не выпадает. Объём считается грубо, как символы, делённые на 2.5, — привычная оценка «символы на 4» для русского занижает почти вдвое.

Единственное место, где я угадывал

Форум-топики. В выгрузке нет поля «топик» у сообщения. Есть служебное сообщение о создании топика с его названием и есть цепочки реплаев. Топик восстанавливается подъёмом вверх по reply_to_message_id до такого сообщения-корня; General — это id 1.

Модель я вывел из документации API и исходников клиента, но не могу утверждать, что в любой реальной выгрузке цепочка каждого сообщения доходит до корня чисто. Те, у кого не дошла, падают в General. Это место, где инструмент может показать не то, и я про него знаю.

Чего он не умеет — и не будет

  • Расшифровок голосовых. Их нет в выгрузке вообще: я проверял по исходникам десктопного клиента, поля с текстом голосового не существует ни в каком виде. [voice 0:42] — предел возможного.
  • Текста со скриншотов. Это отдельная задача и отдельный инструмент.
  • Анонимизации и ролей. Имена уходят в файл как есть.
  • Саммари. Совсем. Его делает чат, куда вы вставите файл.

Есть и честный долг: библиотека потокового разбора отдаёт числовые id обычными числами, поэтому id от шестнадцати цифр и длиннее округляются. На практике таких не встречалось, порча детерминированная и одинаковая в обоих проходах, так что выбор чата всё равно попадает куда надо. Правильная починка — свой сборщик чисел; отложил осознанно.

Самое слабое место — установка. npm install -g @roflochinsky/tgsum — стена для того, кто открывает терминал раз в год. Дальше мастер простой: указать файл, отметить чаты, выбрать папку. Но первый запуск, скорее всего, будет с чужой помощью, и это ровно тот барьер, ради снятия которого стоило бы собрать один исполняемый файл. Пока не собрал.

← Все посты