Self-hosted REST/WebSocket-обёртка над пользовательским Telegram-аккаунтом на базе
TDLib (нативный C++-интерфейс, td::ClientManager) и Drogon. Даёт языконезависимый
HTTP-доступ к аккаунту, скрывая асинхронную природу TDLib за моделью «запрос → ответ»
и доставляя апдейты через WebSocket.
Статус: Stage 0 (каркас). Функциональность моста/авторизации/сообщений — в разработке по этапам (см.
docs/SPEC_ELABORATION.md§12). Сборка только в Docker/CI.
| Файл | Назначение |
|---|---|
TZ_TDLib_Drogon_REST.md |
Основное ТЗ (v2.0) |
docs/SPEC_ELABORATION.md |
Детальная проработка уровня реализации (v3.0) |
docs/DECISIONS.md |
Журнал принятых решений |
docs/GITFLOW.md |
Модель ветвления |
docs/openapi.yaml |
Единый источник истины HTTP-контракта |
- C++20 (корутины Drogon), TDLib собирается своим C++17.
- TDLib линкуется статически через
Td::TdStatic— нативный C++-API не экспортируетсяlibtdjson.so. - Единый OpenSSL 3.0 для TDLib, Drogon и нашего кода.
- Single-account, только WebSocket для апдейтов, шифрование БД TDLib, id в JSON — строками.
- Деплой: multi-arch (amd64 + arm64) Docker, финал — distroless nonroot.
src/
main.cpp — точка входа, /v1/health, --healthcheck
bridge/
transport.hpp — ITdTransport (seam над td::ClientManager)
real_transport.* — RealTdTransport (единственная точка реального TDLib)
clock.hpp — инъекция времени (SteadyClock / FakeClock в тестах)
tests/
fake_transport.hpp — FakeTdTransport (scripted, потокобезопасный)
transport_smoke_test.cpp
docs/openapi.yaml — единый источник истины HTTP-контракта
Dockerfile.builder — образ тулчейн+TDLib+Drogon (пины TDLIB_REF/DROGON_REF)
Dockerfile — образ сервиса (FROM builder) → distroless
CI/CD — GitHub Actions (.github/workflows/ci.yml): lint (clang-format), suppressions-guard, кэшируемый через GHCR builder-образ (TDLib+Drogon), тесты под ASan/TSan, clang-tidy и Release-сборка сервиса. Историческая заметка о переменных прежнего пайплайна — docs/CICD.md.
# 1. Зафиксировать TDLIB_REF (полный git-SHA!) и DROGON_REF.
# 2. Собрать builder-образ (долго; только при смене пинов):
docker build -f Dockerfile.builder \
--build-arg TDLIB_REF="$(grep -oP '(?<=^TDLIB_REF=).*' TDLIB_REF)" \
--build-arg DROGON_REF="$(grep -oP '(?<=^DROGON_REF=).*' DROGON_REF)" \
-t tgw-builder:local .
# 3. Собрать сервис:
docker build --build-arg BUILDER_IMAGE=tgw-builder:local -t telegram-rest-gateway .Локальная сборка вне Docker требует установленных TDLib (Td::TdStatic), Drogon и
OpenSSL 3.0; конфигурация — через CMakePresets.json (cmake --preset dev-debug).
| Переменная | По умолчанию | Назначение |
|---|---|---|
TGW_LISTEN_ADDRESS |
127.0.0.1 |
Адрес прослушивания (в Docker — 0.0.0.0) |
TGW_LISTEN_PORT |
8080 |
Порт HTTP |
TGW_SESSION / TGW_SESSION_FILE |
— | Session string (base64 от td.binlog) для stateless-запуска |
BEARER_TOKENS |
— | По строке на токен: <token>[ <scopes>]; scopes: read,write,admin (без scopes = все) |
TGW_DATABASE_DIR |
/data/session |
Каталог БД TDLib (td.binlog); должен быть на persistent-томе (или восстанавливаться из S3, см. ниже) |
TGW_FILES_DIR |
/data/files |
Каталог файлов TDLib (скачанные/аплоады); тоже должен переживать рестарт пода |
TGW_USE_TEST_DC |
false (0) |
1/true — подключаться к тестовому дата-центру Telegram вместо прод (use_test_dc TDLib) |
TGW_KEEP_ONLINE |
false |
1/true — держать аккаунт online: setOption("online", true) после авторизации + переустановка при каждом реконнекте (connectionStateReady). Делает last-seen аккаунта видимым 24/7 |
TGW_KEEP_ONLINE_INTERVAL_SECONDS |
60 |
Период переотправки setOption("online", true) при TGW_KEEP_ONLINE: TDLib не подтверждает статус сама — без периодического повтора он деградирует на долгом стабильном соединении между реконнектами |
TGW_TDLIB_LOG_VERBOSITY |
1 |
Уровень логирования TDLib (0 — тихо, выше — подробнее); см. Td::TdStatic |
TGW_MAX_UPLOAD_BYTES |
67108864 (64 MiB) |
Лимит тела POST /v1/chats/{chatId}/files (setClientMaxBodySize); больше — 413 |
TGW_WS_MAX_PENDING_BYTES |
8388608 |
WS back-pressure: лимит байт с последнего pong; 0 — выкл |
TGW_MAX_MEMORY_BODY_BYTES |
1048576 |
Порог spool-на-диск для тел запросов (RSS при аплоадах) |
TGW_KAFKA_BROKERS |
— | Kafka/Redpanda bootstrap; пусто — события в Kafka выключены |
TGW_KAFKA_TOPIC |
tgw.updates |
Топик событий |
TGW_KAFKA_CLIENT_ID |
tgw-<session_id> |
client.id продюсера |
TGW_WEBHOOKS_ENABLED |
false |
1/true — включить рассылку вебхуков на mention/dm/reply-события владельцу (см. «Вебхуки…» ниже) |
TGW_WEBHOOK_TIMEOUT_MS |
10000 |
Таймаут HTTP-запроса доставки одного вебхука, мс |
TGW_WEBHOOK_QUEUE_MAX |
10000 |
Ёмкость очереди диспетчера вебхуков; при переполнении новые события дропаются (tgw_webhook_dropped_total) |
TGW_WEBHOOK_SSRF_GUARD |
false |
1/true — блокировать доставку на приватные/loopback/link-local хосты (127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, localhost, IPv6 loopback/ULA/link-local) |
TGW_HUMANIZE_TYPING |
false |
1/true — перед отправкой текстового POST /v1/chats/{chatId}/messages показать "печатает…" и выдержать паузу, пропорциональную длине текста |
TGW_HUMANIZE_CHARS_PER_MINUTE |
200 |
Скорость "печати" для расчёта базовой паузы: base_ms = chars / (chars_per_minute / 60000) |
TGW_HUMANIZE_JITTER_PERCENT |
20 |
Разброс паузы в процентах вокруг базового значения (uniform(1 − jitter, 1 + jitter)) |
TGW_HUMANIZE_MIN_DELAY_MS |
1000 |
Нижняя граница паузы после применения jitter (clamp()) |
TGW_HUMANIZE_MAX_DELAY_MS |
10000 |
Верхняя граница паузы после применения jitter (clamp()) |
TGW_HUMANIZE_ID_WAIT_MS |
4000 |
Сколько ждать подтверждение (updateMessageSendSucceeded/Failed) реального id перед тем, как отдать 202 Accepted без ожидания |
TGW_IDLE_CONNECTION_TIMEOUT_SECONDS |
90 |
Idle-таймаут Drogon-соединения; должен покрывать худший случай humanize-паузы (max_delay_ms + id_wait_ms + запас 2000мс) — иначе старт упадёт с ошибкой конфигурации |
Онлайн-статус (TGW_KEEP_ONLINE). По умолчанию TDLib держит аккаунт offline. При включении
gateway после авторизации шлёт setOption("online", true) и переустанавливает его при каждом
восстановлении соединения (connectionStateReady), чтобы статус пережил реконнекты. Дополнительно
каждые TGW_KEEP_ONLINE_INTERVAL_SECONDS (default 60с) статус переотправляется по таймеру —
на стабильном соединении без реконнектов события не происходит, а TDLib не подтверждает
online-статус сама, поэтому без таймера он деградирует. Учти: аккаунт будет виден как online
(и last-seen обновляется) круглосуточно, пока процесс жив. При штатном завершении спец-действий
не нужно — TDLib на close сам выставит offline.
Имитация человеческой печати (TGW_HUMANIZE_TYPING). По умолчанию выключено — поведение
POST /v1/chats/{chatId}/messages не меняется (202 Accepted + temporary_message_id +
sending_state: pending, финал по WS). При включении гейтвей перед реальной отправкой:
- Считает базовую паузу по длине текста (в UTF-8 кодпоинтах, не байтах — иначе кириллица/эмодзи
завышают паузу):
base_ms = длина / (TGW_HUMANIZE_CHARS_PER_MINUTE / 60000). - Применяет случайный разброс
± TGW_HUMANIZE_JITTER_PERCENT %и приводит результат к границам[TGW_HUMANIZE_MIN_DELAY_MS, TGW_HUMANIZE_MAX_DELAY_MS]. - На всё время паузы шлёт
sendChatAction(chatActionTyping)(аккаунт виден собеседнику как "печатает…"), обновляя действие каждые 4с — TDLib гасит статус печати сам примерно через 5с. - После паузы вызывает
sendMessageкак обычно и доTGW_HUMANIZE_ID_WAIT_MSмс ждёт подтверждение реального id (updateMessageSendSucceeded/Failed) от TDLib.
Контракт ответа в результате тройной: если в окно ожидания успело прийти подтверждение
успешной отправки — 200 OK с полным объектом сообщения (Message, реальный id) и
sending_state: "sent"; если пришло подтверждение ОШИБКИ отправки — обычный код ошибки
(4xx/502, как и для любого другого провала sendMessage); если не успело ничего (таймаут)
или флаг выключен — как и раньше, 202 Accepted с temporary_message_id и
sending_state: "pending" (финал по WS). Клиенту следует обрабатывать все три исхода на этом
эндпоинте.
При старте гейтвей проверяет, что худший случай паузы влезает в TGW_IDLE_CONNECTION_TIMEOUT_SECONDS
(TGW_HUMANIZE_MAX_DELAY_MS + TGW_HUMANIZE_ID_WAIT_MS + запас 2000мс) — иначе падает с понятной
ошибкой конфигурации при загрузке, не давая заведомо рвущимся соединениям уйти в прод.
Если заданы, td.binlog на старте тянется из S3 (при отсутствии локального), периодически и на
graceful shutdown заливается обратно. Позволяет запускать сервис полностью stateless без монтирования
volume. Включается только при заполненных bucket + credentials + endpoint.
| Переменная | По умолчанию | Назначение |
|---|---|---|
TGW_S3_ENDPOINT |
— | http(s)://host[:port] (AWS: https://s3.<region>.amazonaws.com) |
TGW_S3_REGION |
us-east-1 |
Регион для подписи SigV4 |
TGW_S3_BUCKET |
— | Имя бакета |
TGW_SESSION_ID |
default |
Метка инстанса → путь в S3 (сегмент [A-Za-z0-9._-]) |
TGW_S3_PREFIX |
telegram-sessions |
Префикс ключа |
TGW_S3_KEY |
(derived) | Явный ключ-override; иначе <prefix>/<session_id>/td.binlog |
TGW_S3_ACCESS_KEY_ID / _FILE |
— | Access key |
TGW_S3_SECRET_ACCESS_KEY / _FILE |
— | Secret key |
TGW_S3_PATH_STYLE |
true |
true — path-style (MinIO); false — virtual-host (AWS) |
TGW_S3_SYNC_INTERVAL_SECONDS |
300 |
Период фонового бэкапа сессии в S3 |
Несколько аккаунтов на одном бакете. Каждый инстанс получает свой путь по TGW_SESSION_ID:
telegram-sessions/<session_id>/td.binlog. Задавай разный TGW_SESSION_ID на каждый аккаунт
(Telegram account_id недоступен до логина, поэтому метку назначает оператор). Один и тот же
session_id нельзя гонять в двух инстансах одновременно — Telegram убьёт сессию (AUTH_KEY_DUPLICATED).
Scoped-токены. Строка BEARER_TOKENS вида tgw_xxx read выдаёт токен только на чтение
(GET + WS), read,write — плюс мутации, но без /v1/auth/* (логин и session export — только
admin/полный токен). Недостаточный скоуп → 403 INSUFFICIENT_SCOPE. Агентам (MCP) выдавайте
минимально необходимый скоуп. Маршруты Drogon матчатся без учёта регистра, поэтому и проверка
префикса /v1/auth/ регистронезависима: GET /V1/Auth/session/export тоже требует admin.
Секреты (api_id, api_hash, database_encryption_key, Bearer-токены, S3 credentials) — только
через *_FILE / secret manager, никогда в образ/env напрямую.
Если задан TGW_KAFKA_BROKERS, каждый апдейт из allowlist (тот же набор, что в WS) публикуется
в топик TGW_KAFKA_TOPIC. Формат тела — как WS-фрейм (type/update_type/seq/session_id/data);
ключ сообщения — <session_id>:<chat_id> (префикс id аккаунта; порядок в рамках чата
гарантирован партиционированием). Доставка at-least-once: дедупликация у консьюмера по
(session_id, seq); дыра в seq = потеря (см. tgw_kafka_dropped_total). Продюсер никогда
не блокирует приём апдейтов Telegram: при переполнении очереди события дропаются с метрикой.
При TGW_WEBHOOKS_ENABLED=1 гейтвей на каждое входящее сообщение, которое:
- упоминает владельца аккаунта (
@username/text-mention), либо - пришло владельцу в личные сообщения (DM), либо
- является ответом (reply) на сообщение владельца — подтверждается async-резолвом автора
ближайшего родителя (
webhook/context_builder.cpp): если резолв не удался или автор родителя не владелец, событие не отправляется,
строит событие WebhookEvent и рассылает его POST-запросом на URL каждого активного
вебхука из реестра (см. «Управление вебхуками» ниже). Доставка fire-and-forget на отдельном
воркер-пуле: очередь ограничена TGW_WEBHOOK_QUEUE_MAX (переполнение — дроп события с
метрикой tgw_webhook_dropped_total), таймаут одного HTTP-запроса — TGW_WEBHOOK_TIMEOUT_MS.
Ответ получателя не создаёт ретраев: 2xx считается доставкой (tgw_webhook_delivered_total),
всё остальное (не-2xx, транспортная ошибка, неразбираемый URL) — провалом
(tgw_webhook_failed_total).
TGW_WEBHOOK_SSRF_GUARD=1 блокирует доставку на приватные/loopback/link-local хосты
(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, localhost,
IPv6 loopback/ULA/link-local) — полезно, если URL вебхуков задаются недоверенными операторами.
Реестр вебхуков (/v1/webhooks*) требует admin-скоуп Bearer-токена — как и /v1/auth/*:
список и секреты вебхуков определяют, куда утечёт содержимое чужой переписки.
| Заголовок | Значение |
|---|---|
Content-Type |
application/json |
X-TGW-Signature |
sha256=<hex> — hex(HMAC-SHA256(secret вебхука, тело запроса как есть)) |
X-TGW-Event-Id |
event_id события (см. ниже) — для дедупликации на стороне приёмника |
{
"event_id": "default:-1001234567890:4321",
"session_id": "default",
"owner_id": "123456789",
"trigger_reason": "mention",
"received_at": 1753876543,
"message": {
"id": "4321",
"chat": { "id": "-1001234567890" },
"date": 1753876543,
"sender": { "id": "987654321" },
"text": "@owner глянь плз",
"entities": [
{ "type": "mention", "offset": 0, "length": 6 }
],
"reply_to_message_id": "4300"
},
"reply_chain": [],
"chain_truncated": false
}event_id—<session_id>:<chat_id>:<message_id>.trigger_reason—"mention"|"dm"|"reply".messageи каждый элементreply_chain— одна и та же плоская проекция сообщения (webhookMessageToJson, отдельная отcontentвGET /v1/chats/{chatId}/messages):id,chat.id,date(unixtime),sender.id(если у сообщения есть отправитель),text(пусто для нетекстового контента без подписи),entities[](offset/length+ type-специфичные поля, напримерmention_name.user_id,text_url.url),attachment(та же схема полей, что иcontentв истории сообщений — присутствует, только если контент не чистый текст) иreply_to_message_id(только если сообщение само является ответом). Важно: в этой проекции нетchat.type/chat.titleиsender.name/sender.username— payload вебхука минимальнее полной DTO сообщения.reply_chain— цепочка родителей reply, ближайший первым (родитель → корень), до 20 звеньев;chain_truncated: true, если цепочка длиннее лимита или резолв очередного родителя оборвался ошибкой (само событие при этом всё равно отправляется — усечение не блокирует доставку).
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // именно СЫРОЕ тело запроса, до JSON.parse
.digest('hex');
const got = signatureHeader || '';
return expected.length === got.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}POST /v1/webhooks— регистрирует вебхук. Тело:{"url", "secret"?, "active"?}(activeпо умолчаниюtrue). Ответ (общий конверт гейтвея):{"ok": true, "data": {"id", "url", "active"}}—secretв ответе не возвращается. ПовторныйPOSTс тем жеurlобновляет запись на месте (не дублирует), так какidдетерминирован отurl.GET /v1/webhooks— список зарегистрированных вебхуков (безsecret).DELETE /v1/webhooks/{id}— удаляет вебхук;200при удалении,404— такогоidнет.id—hex(sha256(url))[0:16], отдаётся в ответеPOST/GET.
Полная схема запросов/ответов и коды ошибок — docs/openapi.yaml.
archiver/ — консьюмер Kafka-событий → SQLite+FTS5: полнотекстовый
поиск по всей сохранённой истории (GET :8090/search?q=), включая правки и удалённые.
Агенту доступен как MCP-tool telegram_search_history.
mcp/ — MCP-сервер (TypeScript, stdio): Claude Code/Desktop и любой
MCP-клиент получают 14 инструментов аккаунта (сообщения, реакции, resolve, медиа).
Один сервер = один аккаунт; токен = полный доступ, храни как секрет.
Чарт: deploy/helm/telegram-rest-gateway. Особенности:
одна сессия = один под (strategy Recreate, реплики не масштабировать — иначе
AUTH_KEY_DUPLICATED), без PVC (сессии в S3), readiness на /v1/health (неавторизованный под
должен принимать трафик для логина через /ui). Секреты — через existingSecret.
kubectl create secret generic telegram-rest-gateway \
--from-literal=API_ID=... --from-literal=API_HASH=... \
--from-literal=DATABASE_ENCRYPTION_KEY=... --from-literal=BEARER_TOKENS=... \
--from-literal=TGW_S3_ACCESS_KEY_ID=... --from-literal=TGW_S3_SECRET_ACCESS_KEY=...
helm install tgw deploy/helm/telegram-rest-gateway \
--set 'accounts[0].sessionId=<account_id>'GET /metrics — Prometheus text format (без Bearer): auth-состояние, WS-подписчики,
inflight моста, счётчики HTTP/апдейтов.
GET /ui — самодостаточная страница логина (без Bearer-фильтра; токен вводится в форме):
выбор метода QR-код (авто-обновление при ротации ссылки TDLib) или телефон
(номер → код → 2FA). После авторизации показывает аккаунт.
docs/openapi.yaml — единый источник истины HTTP-контракта (все эндпоинты,
конверты, коды ошибок).
MIT.