Skip to main content
Glama
Toligrim

letopis-mcp

by Toligrim
README.md
<div align="center">

# 📜 Летопись (Letopis)

**Движок архива и умного поиска по истории Telegram-чатов**

Сырые сообщения в JSONL · полнотекстовый поиск с русской морфологией · качалка с менеджером

[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![Telethon](https://img.shields.io/badge/telethon-1.36%2B-2CA5E0?logo=telegram&logoColor=white)](https://github.com/LonamiWebs/Telethon)
[![SQLite FTS5](https://img.shields.io/badge/index-SQLite%20FTS5-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/fts5.html)
[![License](https://img.shields.io/badge/license-unlicensed-lightgrey)]()

</div>

---

## Идея

Летопись — это не бот и не сервис, а **CLI-инструмент**, заточенный под то, чтобы
LLM-агент (в первую очередь [Claude Code](https://claude.com/claude-code)) мог
читать историю ваших Telegram-чатов и отвечать по ней на вопросы, как по обычной
базе знаний.

Архив хранится как обычные файлы — `.jsonl`, по одному на чат и месяц,
append-only. Поверх них строится индекс SQLite с полнотекстовым поиском (FTS5),
который понимает русские словоформы: запрос «хостинг» находит сообщения со
словом «хостингами». К поиску подключены расшифровки голосовых, имена файлов
и текст опросов.

```
$ ./tg search переезд хостинг --chat devops --from 2025-06
```

> **Движок и данные разделены.** Этот репозиторий содержит только код —
> сами архивы чатов, `config.toml`, `.env` и сессия Telegram живут в отдельном
> приватном репозитории, который держит вас в контроле над тем, что публично,
> а что нет. Подробнее — в разделе [«Устройство»](#-устройство).

---

## ✨ Возможности

| | |
|---|---|
| 🔎 **Полнотекстовый поиск** | SQLite FTS5 + `pymorphy3`: ищет по леммам, а не только точным словам |
| 📦 **Архив как файлы** | `archive/<chat_id>/<YYYY-MM>.jsonl`, append-only, ничего не переписывается задним числом |
| ⬇️ **Качалка с менеджером** | `download` / `sync` докачивают только новое; `manifest.json` помнит, что отслеживается |
| 🎙️ **Транскрипция голосовых** | локально (faster-whisper), через Telegram Premium или OpenAI Whisper API |
| 🌐 **Веб-просмотрщик** | чаты → топики-чипы, бесконечная прокрутка, фильтры, плеер голосовых, прыжки по реплаям |
| ⌨️ **TUI-просмотрщик** | тот же функционал в терминале (`textual`) |
| 👥 **Несколько аккаунтов** | разные чаты можно скачивать разными Telegram-аккаунтами |
| 🤖 **Заточен под агентов** | JSON-вывод, компактные короткие форматы, стабильный CLI-контракт |

---

## 🚀 Быстрый старт

```sh
git clone https://github.com/Toligrim/letopis.git
cd letopis
python3 -m venv .venv && .venv/bin/pip install -e .
.venv/bin/pip install faster-whisper   # опционально: локальная транскрипция голосовых
```

Летопись — это только движок. Чтобы подключить его к конкретному архиву, создайте
**отдельный репозиторий с данными** и положите туда обёртку `./tg`:

```sh
#!/bin/sh
exec "$HOME/projects/letopis/.venv/bin/tg" "$@"
```

Дальше всё запускается из корня репозитория с данными:

```sh
chmod +x tg
./tg login              # авторизация Telegram-сессии (телефон / код / 2FA)
./tg download --chat mychat --media all
./tg index && ./tg meta
./tg search привет
```

Движок сам находит корень с данными: при запуске `tg` ищет от текущей директории
вверх папку, где рядом лежат `config.toml` и `archive/` (либо это явно задаётся
переменной окружения `TG_ROOT`).

## 🤖 Read-only MCP для ChatGPT

Letopis может работать как **read-only MCP retrieval gateway** для ChatGPT:
сервер использует тот же `data/index.db`, что и обычный поиск, но отдаёт только
пять безопасных retrieval-инструментов — обзор архива, поиск, агрегаты, выборку
сообщений и локальный контекст. MCP-процесс не синхронизирует Telegram, не
скачивает файлы и не меняет индекс.

### Установка и запуск

Установите MCP SDK и тестовые зависимости в окружение движка:

```sh
.venv/bin/pip install -e ".[mcp,test]"
```

Запуск через entrypoint:

```sh
.venv/bin/letopis-mcp
```

Альтернативная форма — `.venv/bin/python -m tgarchive.mcp.server`. По умолчанию
сервер слушает `http://127.0.0.1:8765/mcp` и принимает только loopback-адреса.
Для production задайте стабильный секрет курсоров и путь к индексу в окружении
процесса, например:

```sh
export LETOPIS_MCP_DB=/srv/letopis-data/data/index.db
export LETOPIS_MCP_CURSOR_SECRET='случайный-длинный-секрет'
.venv/bin/letopis-mcp
```

### Переменные окружения

| Переменная | По умолчанию | Назначение |
|---|---:|---|
| `LETOPIS_MCP_DB` | значение `[general].db` из `config.toml`, обычно `data/index.db` | Путь к SQLite-индексу; относительный путь считается от корня проекта. |
| `LETOPIS_MCP_CURSOR_SECRET` | нет; временный случайный секрет на процесс | HMAC-SHA256 для непрозрачных курсоров. **Обязателен в production**: без него курсоры не переживают рестарт процесса. |
| `LETOPIS_MCP_HOST` | `127.0.0.1` | Loopback-адрес bind; приложение отклоняет нелокальные адреса. |
| `LETOPIS_MCP_PORT` | `8765` | TCP-порт Streamable HTTP endpoint. |
| `LETOPIS_MCP_LOG_LEVEL` | `INFO` | Уровень структурированных логов (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`). |
| `LETOPIS_MCP_MAX_CONCURRENCY` | `8` | Максимум конкурентных операций с read-only БД. |
| `LETOPIS_MCP_QUERY_TIMEOUT_SECONDS` | `30.0` | Дедлайн SQLite-запроса и ожидания слота concurrency. |
| `LETOPIS_MCP_ROLLING_CALLS_MAX` | `60` | Максимум завершённых вызовов в глобальном rolling-окне. |
| `LETOPIS_MCP_ROLLING_CHARS_MAX` | `250000` | Максимум отданных символов в том же окне. |
| `LETOPIS_MCP_ROLLING_WINDOW_SECONDS` | `600` | Длина rolling-окна в секундах. |

Rate limit намеренно глобальный для одного процесса: в v1 нет OAuth и
идентифицированных principals, поэтому это не per-user ACL. MCP читает эти
переменные как конфигурацию процесса и не загружает `.env` автоматически.

### Подключение к ChatGPT

Проверенный production-паттерн оставляет MCP-сервер на loopback и публикует
наружу только HTTPS-эндпоинт через Cloudflare Tunnel:

```text
ChatGPT → https://your-domain.example.com/mcp?k=<секрет>
        → Cloudflare WAF → Cloudflare Tunnel → 127.0.0.1:8765/mcp
```

В этом варианте не используется «OpenAI Secure MCP Tunnel»: публичной
self-serve документации OpenAI по настройке серверной стороны этого механизма
сейчас нет. Пункт «Туннель» в интерфейсе ChatGPT — отдельный нативный механизм;
ниже описан рабочий вариант с обычным публичным HTTPS-туннелем.

#### ⚙️ systemd-сервис

Запустите `letopis-mcp` как отдельный systemd unit под непривилегированным
пользователем. Файл unit можно разместить, например, в
`/etc/systemd/system/letopis-mcp.service`:

```ini
[Unit]
Description=Letopis read-only MCP server
After=network.target

[Service]
Type=simple
User=<непривилегированный-пользователь>
WorkingDirectory=<корень-репозитория-с-данными>
EnvironmentFile=/etc/default/letopis-mcp
ExecStart=<venv>/bin/letopis-mcp
Restart=always
RestartSec=5
NoNewPrivileges=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target
```

Файл `/etc/default/letopis-mcp` должен находиться вне git-репозитория и
содержать как минимум конфигурацию SQLite и стабильный секрет курсоров:

```sh
LETOPIS_MCP_DB=<корень-репозитория-с-данными>/data/index.db
LETOPIS_MCP_CURSOR_SECRET=<стабильный-секрет>
```

Замените плейсхолдеры на значения своей установки и закройте файл от лишних
пользователей. После установки unit активируйте его так:

```sh
sudo systemctl daemon-reload
sudo systemctl enable --now letopis-mcp.service
```

Дополнительный `ReadWritePaths` не нужен: сервер сам открывает SQLite в режиме
`mode=ro`, поэтому ему нужны только права чтения на индекс и необходимые
SQLite sidecar-файлы.

#### 🌐 Cloudflare Tunnel и HTTPS

Настройте `cloudflared` на проксирование публичного имени к loopback-порту
MCP-сервера. Существенная часть `config.yml` выглядит так:

```yaml
ingress:
  - hostname: your-domain.example.com
    service: http://127.0.0.1:8765
    originRequest:
      httpHostHeader: "127.0.0.1:8765"
  - service: http_status:404
```

> **Важно про `Host` и DNS-rebinding-защиту.** MCP SDK по умолчанию включает
> защиту от DNS rebinding, когда сервер привязан к loopback-адресу
> (`127.0.0.1`/`localhost`): заголовок `Host` проверяется по списку
> `127.0.0.1:*`, `localhost:*`, `[::1]:*`. Cloudflare Tunnel по умолчанию
> передаёт на origin внешний `Host`, например
> `your-domain.example.com`, поэтому без переписывания сервер отвечает
> `421 Invalid Host header`. Эта проверка реализована в
> `mcp/server/transport_security.py` MCP SDK.
>
> Параметр `originRequest.httpHostHeader` переписывает заголовок только при
> передаче запроса к локальному origin и сохраняет защиту SDK включённой.
> Порт после адреса обязателен: `"127.0.0.1"` без `:8765` не пройдёт проверку,
> потому что шаблон `127.0.0.1:*` требует порт. Это правильное решение,
> ослаблять DNS-rebinding-защиту в приложении не нужно.

#### 🛡️ Авторизация на границе через Cloudflare WAF

MCP v1 не реализует аутентификацию (см. раздел
[«Безопасность deployment»](#безопасность-deployment)), поэтому публичный
эндпоинт нужно защищать до того, как запрос попадёт в приложение. Cloudflare
Access — один из вариантов, но включение Zero Trust-плана требует
привязки карты даже на бесплатном тарифе. Проверенная бескарточная альтернатива
— **WAF Custom Rule**, доступная на бесплатном плане (до пяти правил).

В Cloudflare откройте **Security → Security rules → Custom rules** и создайте
правило:

- условие: `Hostname equals your-domain.example.com` **AND** `URI Query String does not contain "k=<секрет>"`;
- действие: `Block`.

В URL ChatGPT передайте тот же WAF-секрет query-параметром:

```text
https://your-domain.example.com/mcp?k=<секрет>
```

Секрет WAF — отдельное значение; не переиспользуйте для него
`LETOPIS_MCP_CURSOR_SECRET`. При такой проверке запрос без `k` получает `403`
ещё на границе Cloudflare, и MCP-сервер его не видит. Запрос с параметром
доходит до сервера и обрабатывается нормально.

Это грубее полноценной per-request-аутентификации: статичный секрет живёт в
URL и может попасть в историю или логи. Но для сценария «URL случайно узнали
или endpoint просканировали» такой edge-gate закрывает доступ без изменений в
сервере и без дополнительных затрат. При утечке секрет следует заменить и
обновить WAF-правило и URL коннектора.

#### 🔌 Подключение в ChatGPT

В ChatGPT создайте custom connector / плагин в режиме разработчика:

1. Откройте **Developer mode → Settings → Plugins → `+`**.
2. Для `Connection` выберите **«URL-адрес сервера»**, а не **«Туннель»**.
3. Укажите URL `https://your-domain.example.com/mcp?k=<секрет>`.
4. Для `Authentication` выберите **«Без авторизации»**. Это означает, что
   MCP-протокол не использует OAuth: защита уже выполнена правилом Cloudflare
   WAF. В форме также могут быть варианты OAuth и «Смешанные».
5. Создайте подключение.

В этой схеме ChatGPT успешно обнаруживает все пять retrieval-инструментов.

### Безопасность deployment

MCP-процессу нужны только `data/index.db` и необходимые SQLite sidecar-файлы
`data/index.db-wal` / `data/index.db-shm`. Ему не должны быть доступны
`.env`, `telegram.session*`, `archive/`, media или manifest. Запускайте сервер
под отдельным Unix-пользователем с минимальными правами, а синхронизацию и
индексацию выполняйте отдельным процессом с нужными правами записи.

MCP v1 не реализует аутентификацию: endpoint сам не проверяет OAuth или другой
identity пользователя. Поэтому при публичной публикации нужна внешняя защита
на границе, например описанная выше WAF Custom Rule.

---

## 🗂 Устройство

```
репозиторий с данными/
├── config.toml              # настройки: аккаунты, транскрипция, веб-порт
├── .env                     # api_id / api_hash Telegram
├── telegram.session         # сессия аккаунта (и доп. сессии из [accounts])
├── tg -> letopis/.venv/bin/tg   # обёртка-энтрипоинт
├── data/
│   └── index.db             # SQLite + FTS5 — производный, пересобирается
└── archive/
    ├── manifest.json        # какие чаты отслеживаем, каким аккаунтом, какие медиа качаем
    └── <chat_id>/
        ├── 2025-06.jsonl    # сырые сообщения этого месяца — источник истины
        ├── 2025-07.jsonl
        ├── transcripts.jsonl   # расшифровки голосовых/кружков
        ├── media_index.jsonl   # реестр скачанных файлов
        └── media/               # сами файлы
```

- **JSONL — источник истины.** Файлы по месяцам, `sync` только дописывает новые
  сообщения, старые никогда не трогает.
- **`index.db` — производный слой.** Можно удалить и пересобрать (`./tg index --rebuild`)
  в любой момент без потери данных.
- **`manifest.json` — менеджер.** Переживает пересборку индекса; хранит, какие
  чаты/топики отслеживаются и какие типы медиа для них качать.

Такое разделение (движок в git, открыто · данные — отдельно, приватно) позволяет
свободно развивать и делиться кодом, не рискуя утечкой переписки.

---

## 🧭 Команды

### Поиск — основное для агента

| Команда | Что делает |
|---|---|
| `tg search <слова…>` | Полнотекстовый поиск. Флаги: `--any` (OR вместо AND), `--chat`, `--topic`, `--sender`, `--from` / `--to`, `--media`, `--around N` (контекст вокруг находок), `--count`, `--by-chat` / `--by-topic` / `--by-sender` (агрегаты), `--rank` (по релевантности), `--json`, `--short N`, `--limit N\|0` |
| `tg dump --chat X [--topic N]` | Хронологический кусок переписки целиком |
| `tg context --chat X --id N` | Сообщения вокруг конкретного (`--before` / `--after` / `--whole-chat`) |
| `tg chats` | Обзор чатов в архиве |
| `tg topics --chat X` | Топики форум-чата |
| `tg status` | Состояние архива и индекса |

### Просмотрщики — ручной режим

| Команда | Что делает |
|---|---|
| `tg web` | Локальный веб-интерфейс: чаты → топики-чипы, бесконечная прокрутка, поиск с фильтрами, прыжок к дате, фильтр по автору (клик по нику), фото/видео инлайн, плеер голосовых с расшифровкой, реплаи с прыжком по треду, ссылки `t.me`. Порт — в `config.toml [web]` |
| `tg tui` | То же самое в терминале: `/` поиск · `g` дата · `s` автор · `o`/`n` старее/новее · `c` контекст · `m` открыть в Telegram · `f` открыть файл · `Esc` назад · `q` выход |

### Качалка и менеджер

| Команда | Что делает |
|---|---|
| `tg dialogs` | Все чаты аккаунта (✓ — уже в архиве) |
| `tg download --chat <имя\|id\|@user>` | Скачать чат/топики и поставить на отслеживание. Флаги: `--topic N`, `--from 2025-01`, `--media photo,voice\|all\|none` |
| `tg sync [--chat X]` | Докачать новые сообщения всех отслеживаемых чатов |
| `tg media --chat X --media voice` | Докачать файлы для уже скачанных сообщений |
| `tg transcribe [--provider …]` | Расшифровать голосовые в текст, чтобы он попал в поиск |
| `tg untrack --chat X` | Снять чат с отслеживания (файлы остаются на диске) |
| `tg meta` / `tg index` | Обновить названия чатов / доиндексировать архив |
| `tg login [--account имя]` | Авторизовать Telegram-сессию (телефон / код / 2FA) |

Чат можно указывать как `id`, алиас из `config.toml`, часть названия, `@username`
или ссылку `t.me/...`. Новые сообщения скачиваются со всеми реакциями, опросами
и сервисными событиями.

---

## 🎙 Транскрипция голосовых

Провайдер задаётся в `config.toml [transcription]`:

| Провайдер | Стоимость | Требования |
|---|---|---|
| `whisper-local` | бесплатно, локально | `faster-whisper`, модель `small` по умолчанию |
| `telegram` | бесплатно | Telegram Premium на аккаунте |
| `openai` | платно (Whisper API) | `OPENAI_API_KEY` в `.env` |

---

## 👥 Несколько аккаунтов

```toml
[accounts]
default = "telegram.session"
backup  = "sessions/backup.session"
```

`tg login --account backup` авторизует новую сессию. У `download` / `sync` /
`dialogs` / `meta` есть флаг `--account`. Каждый чат в манифесте закреплён за
своим аккаунтом.

---

## 🗺 Статус

| Этап | Статус | Что входит |
|---|---|---|
| A | ✅ готово | Индекс, поиск, CLI, интеграция с Claude Code |
| B | ✅ готово | Качалка (`download`/`sync`, append-only), медиа по настройкам, бэкфилл, транскрипция, менеджер (`manifest.json`), несколько аккаунтов, FloodWait-защита |
| C | ✅ готово | Просмотрщики: `tg web` (браузер, медиа) и `tg tui` (терминал) |

**Дальше:** автосинк по расписанию, OCR картинок, Azure Speech-провайдер, экспорт выборок.

---

## 🔒 Безопасность

`telegram.session` и `.env` дают **полный доступ** к Telegram-аккаунту.
Держите их в отдельном приватном репозитории с данными, не коммитьте в этот
и никуда не публикуйте.

---

<div align="center">

Сделано для того, чтобы агент помнил переписку лучше, чем вы сами.

</div>