Files
IdP/apps/docs/lib/docs-pages.ts
2026-06-26 13:01:52 +03:00

2211 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type DocBlock =
| { type: 'paragraph'; text: string }
| { type: 'list'; items: string[] }
| { type: 'code'; language: string; code: string; title?: string }
| { type: 'callout'; variant: 'info' | 'warning' | 'tip'; title: string; text: string }
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'oauth-examples' }
| { type: 'auth-login-examples' }
| { type: 'auth-ldap-examples' }
| { type: 'bot-examples' }
| { type: 'api-reference' };
export interface DocSection {
id: string;
title: string;
blocks: DocBlock[];
}
export interface DocPage {
slug: string;
title: string;
description: string;
sections: DocSection[];
}
export const docPages: DocPage[] = [
{
slug: 'getting-started',
title: 'Начало работы',
description: 'Обзор экосистемы Identity Provider, порты сервисов и быстрый локальный запуск.',
sections: [
{
id: 'overview',
title: 'Обзор',
blocks: [
{
type: 'paragraph',
text: 'Lendry ID — enterprise Identity Provider в стиле Yandex ID: единый аккаунт, OAuth 2.0, LDAP/LDAPS, семейные группы, чат, Telegram Bot API, PIN-блокировка сессий и админ-панель. Монорепозиторий состоит из NestJS-микросервисов, Go-сервисов и Next.js-приложений.'
},
{
type: 'callout',
variant: 'info',
title: 'Домен',
text: 'Production-домен задаётся в админке (PROJECT_DOMAIN и PUBLIC_API_URL). Локально API — порт 3000, frontend — 3002, документация — 3003. При same-origin деплое PUBLIC_API_URL обычно https://ваш-домен/idp-api.'
}
]
},
{
id: 'requirements',
title: 'Требования',
blocks: [
{
type: 'list',
items: [
'Node.js 20.19+ и npm 10+',
'Docker и Docker Compose',
'PostgreSQL 16, Redis 7, RabbitMQ 3.13, MinIO',
'Go 1.23+ (для локальной сборки media-ws и ldap-auth)'
]
}
]
},
{
id: 'quick-start',
title: 'Быстрый запуск',
blocks: [
{
type: 'callout',
variant: 'tip',
title: 'Production на сервере',
text: 'Для развёртывания на Linux с Docker, Nginx и SSL используйте скрипт install.sh — подробности в разделе «Развёртывание на сервере».'
},
{
type: 'code',
language: 'bash',
title: 'Сервер (рекомендуется)',
code: `git clone https://git.lendry.ru/lendry/IdP.git
cd IdP
chmod +x install.sh
./install.sh`
},
{
type: 'code',
language: 'bash',
title: 'Локально через Docker',
code: `git clone <repository-url> lendry-id
cd lendry-id
docker compose up -d --build
# Проверка
curl http://localhost:3000/health
curl http://localhost:3003/docs/getting-started`
},
{
type: 'table',
headers: ['Сервис', 'Порт', 'Назначение'],
rows: [
['api-gateway', '3000', 'REST API, Swagger'],
['sso-core', '3001 / 50051', 'Бизнес-логика, gRPC'],
['frontend', '3002', 'UI входа и профиля'],
['docs', '3003', 'Документация'],
['media-ws', '8085', 'WebSocket уведомлений и чата'],
['ldap-auth', '8086', 'LDAP/LDAPS аутентификация'],
['MinIO', '9000 / 9001', 'Хранилище медиа'],
['RabbitMQ', '5672 / 15672', 'Очереди событий']
]
}
]
},
{
id: 'first-user',
title: 'Первый пользователь',
blocks: [
{
type: 'paragraph',
text: 'Первый зарегистрированный пользователь автоматически получает права супер-администратора (isSuperAdmin). Race condition защищена Redis-lock и Serializable-транзакцией PostgreSQL.'
},
{
type: 'code',
language: 'bash',
title: 'Регистрация через API',
code: `curl -X POST http://localhost:3000/auth/register \\
-H "Content-Type: application/json" \\
-d '{"displayName":"Администратор","email":"admin@example.com","password":"SecurePass123"}'`
}
]
}
]
},
{
slug: 'architecture',
title: 'Архитектура',
description: 'Схема микросервисов, потоки данных и технологический стек.',
sections: [
{
id: 'stack',
title: 'Технологический стек',
blocks: [
{
type: 'table',
headers: ['Компонент', 'Технологии'],
rows: [
['sso-core', 'NestJS, Prisma 7+, PostgreSQL, gRPC'],
['api-gateway', 'NestJS, REST, Swagger'],
['frontend / docs', 'Next.js App Router, React, Tailwind, shadcn/ui'],
['media-ws', 'Go, Gorilla WebSocket, Redis, RabbitMQ'],
['ldap-auth', 'Go, go-ldap'],
['Инфраструктура', 'Docker, Redis, RabbitMQ, MinIO']
]
}
]
},
{
id: 'flow',
title: 'Поток запросов',
blocks: [
{
type: 'paragraph',
text: 'Клиент (браузер или стороннее приложение) обращается к api-gateway по REST. Gateway проксирует вызовы в sso-core через gRPC. Медиафайлы загружаются в MinIO через presigned URL. Realtime-события (уведомления, чат) доставляются через media-ws по WebSocket с JWT-авторизацией.'
},
{
type: 'list',
items: [
'JWT access + refresh tokens для сессий',
'PIN-код: при включении сессия создаётся с pinVerified=false',
'SystemSetting — динамические бизнес-правила без хардкода во frontend',
'LinkedAccount — привязка LDAP, Google, Yandex и других провайдеров',
'Telegram Bot API — совместимый /bot{token}/{method} на api-gateway, BotFather REST на /bots'
]
}
]
}
]
},
{
slug: 'deployment',
title: 'Развёртывание на сервере',
description: 'Автоматическая установка через install.sh: Docker, Nginx, SSL и отдельные домены для каждого сервиса.',
sections: [
{
id: 'install-script',
title: '1. Автоматическая установка (install.sh)',
blocks: [
{
type: 'paragraph',
text: 'Скрипт install.sh разворачивает весь стек одной командой: Docker, Nginx, генерация .env, reverse proxy. Поддерживает локальную сеть без интернета (--intranet), самоподписанный HTTPS и Let\'s Encrypt для публичных серверов. На Windows Server Nginx запускается в Docker-контейнере.'
},
{
type: 'callout',
variant: 'tip',
title: 'Локальная сеть (AD, .lpr, .local)',
text: 'Если домены только внутри LAN и Let\'s Encrypt недоступен — используйте ./install.sh --intranet --ssl none. Доступ: http://api.idpmvk.lpr без выхода в интернет.'
},
{
type: 'code',
language: 'bash',
title: 'Быстрый старт',
code: `git clone https://git.lendry.ru/lendry/IdP.git
cd IdP
chmod +x install.sh
./install.sh`
},
{
type: 'code',
language: 'bash',
title: 'Production без вопросов',
code: `./install.sh --install --yes \\
--api-domain api.id.example.ru \\
--frontend-domain id.example.ru \\
--docs-domain docs.example.ru \\
--email admin@example.ru`
},
{
type: 'code',
language: 'bash',
title: 'Установка без клонирования',
code: `curl -fsSL https://git.lendry.ru/lendry/IdP/raw/branch/main/install.sh | bash`
}
]
},
{
id: 'intranet-network',
title: '2. Локальная сеть (без интернета)',
blocks: [
{
type: 'paragraph',
text: 'Для корпоративной LAN, Windows Server и внутренних доменов (например idpmvk.lpr) не нужен доступ из интернета и Let\'s Encrypt. Скрипт настраивает Nginx на HTTP или HTTPS с самоподписанным сертификатом (openssl). На Windows Server Nginx работает в Docker (профиль proxy).'
},
{
type: 'code',
language: 'bash',
title: 'Рекомендуемая команда (HTTP)',
code: `./install.sh --intranet --install --yes --offline \\
--api-domain api.idpmvk.lpr \\
--frontend-domain id.idpmvk.lpr \\
--docs-domain docs.idpmvk.lpr \\
--ssl none`
},
{
type: 'code',
language: 'bash',
title: 'HTTPS самоподписанный (опционально)',
code: `./install.sh --intranet --install --yes --offline \\
--api-domain api.idpmvk.lpr \\
--frontend-domain id.idpmvk.lpr \\
--docs-domain docs.idpmvk.lpr \\
--ssl selfsigned`
},
{
type: 'table',
headers: ['SSL_TYPE', 'Описание', 'Интернет'],
rows: [
['none', 'http://api.idpmvk.lpr — без сертификатов', 'Не нужен'],
['selfsigned', 'https://... — openssl, предупреждение браузера', 'Не нужен'],
['letsencrypt', 'Публичный HTTPS', 'Нужен']
]
},
{
type: 'callout',
variant: 'warning',
title: 'DNS на клиентах',
text: 'Пропишите A-записи во внутреннем DNS Active Directory или файл hosts на каждом ПК: IP-сервера → api.idpmvk.lpr, id.idpmvk.lpr, docs.idpmvk.lpr. Windows: C:\\Windows\\System32\\drivers\\etc\\hosts'
},
{
type: 'code',
language: 'text',
title: 'Пример hosts (Windows / Linux)',
code: `192.168.1.10 api.idpmvk.lpr
192.168.1.10 id.idpmvk.lpr
192.168.1.10 docs.idpmvk.lpr`
},
{
type: 'callout',
variant: 'info',
title: 'Windows Server',
text: 'Запускайте install.sh из Git Bash или WSL. Docker Desktop / Docker Engine должен быть установлен заранее (--offline). Nginx поднимается контейнером на портах 80 и 443.'
}
]
},
{
id: 'server-requirements',
title: '3. Требования к серверу',
blocks: [
{
type: 'list',
items: [
'Ubuntu 22.04+ / Debian 12+ / Fedora / RHEL (Linux)',
'Минимум 4 GB RAM, 2 vCPU, 40 GB SSD',
'Отдельные домены или поддомены для API, Frontend и Docs (A-запись → IP сервера)',
'Открытые порты 80 и 443 (остальные сервисы доступны только через Nginx на localhost)',
'Права sudo/root для установки Docker и Nginx'
]
}
]
},
{
id: 'domains',
title: '4. Отдельные домены для сервисов',
blocks: [
{
type: 'paragraph',
text: 'При установке и при повторной настройке (--configure-domains) можно указать свой домен для каждого компонента. Текущие значения подставляются по умолчанию — достаточно изменить только нужные.'
},
{
type: 'table',
headers: ['Сервис', 'Параметр', 'Пример', 'Nginx → порт'],
rows: [
['API / Swagger', '--api-domain', 'api.id.example.ru', '127.0.0.1:3000'],
['Frontend (UI)', '--frontend-domain', 'id.example.ru', '127.0.0.1:3002'],
['Документация', '--docs-domain', 'docs.example.ru', '127.0.0.1:3003'],
['WebSocket', '--ws-domain', 'пусто = API + /ws', '127.0.0.1:8085'],
['MinIO S3', '--minio-domain', 'опционально', '127.0.0.1:9000'],
['MinIO Console', '--minio-console-domain', 'опционально', '127.0.0.1:9001']
]
},
{
type: 'callout',
variant: 'info',
title: 'WebSocket',
text: 'Если --ws-domain не указан, WebSocket проксируется на том же домене, что и API, по пути /ws (wss://api.example.ru/ws). Для отдельного домена укажите --ws-domain ws.example.ru.'
}
]
},
{
id: 'install-menu',
title: '5. Меню управления (повторный запуск)',
blocks: [
{
type: 'paragraph',
text: 'Если файл .env уже существует, повторный вызов ./install.sh открывает интерактивное меню — как у панелей с автоматическим reverse proxy.'
},
{
type: 'table',
headers: ['Пункт', 'Действие'],
rows: [
['1', 'Полная установка / переустановка стека'],
['2', 'Настроить или изменить домены, Nginx и SSL'],
['3', 'Обновить SSL-сертификаты (Let\'s Encrypt)'],
['4', 'Перезапуск / пересборка контейнеров'],
['5', 'Статус сервисов и проверка Nginx'],
['6', 'Удалить конфиги Nginx IdP'],
['0', 'Выход']
]
},
{
type: 'code',
language: 'bash',
title: 'Смена доменов после установки',
code: `# Интерактивно
./install.sh --configure-domains
# Или через меню
./install.sh
# → пункт 2
# Пример: вынести Swagger на отдельный домен
./install.sh --configure-domains \\
--api-domain swagger.id.example.ru \\
--frontend-domain id.example.ru \\
--docs-domain docs.example.ru`
}
]
},
{
id: 'install-commands',
title: '6. Команды и флаги',
blocks: [
{
type: 'table',
headers: ['Команда / флаг', 'Описание'],
rows: [
['./install.sh', 'Интерактивное меню (если .env есть) или первая установка'],
['./install.sh --install', 'Полная установка'],
['./install.sh --configure-domains', 'Изменить домены и пересобрать frontend/docs'],
['./install.sh --renew-ssl', 'Обновить сертификаты Let\'s Encrypt'],
['./install.sh --restart', 'docker compose up -d --build'],
['./install.sh --status', 'Статус контейнеров и доменов'],
['./install.sh --intranet', 'Локальная сеть, HTTP по умолчанию'],
['./install.sh --ssl none', 'Только HTTP через Nginx'],
['./install.sh --ssl selfsigned', 'HTTPS самоподписанный'],
['./install.sh --offline', 'Не скачивать Docker из интернета'],
['./install.sh --nginx-mode docker', 'Nginx в контейнере (Windows)'],
['./install.sh --local', 'localhost:3000/3002/3003 без Nginx'],
['./install.sh --yes', 'Без интерактивных вопросов'],
['FORCE_ENV=1 ./install.sh', 'Пересоздать .env с новыми секретами']
]
},
{
type: 'code',
language: 'bash',
title: 'Локальная разработка без доменов',
code: `./install.sh --local
# или
./install.sh --install --local --yes`
}
]
},
{
id: 'install-automation',
title: '7. Что делает скрипт автоматически',
blocks: [
{
type: 'list',
items: [
'Устанавливает curl, git, openssl, jq (apt/dnf)',
'Устанавливает Docker и Docker Compose v2 (get.docker.com)',
'Добавляет пользователя в группу docker',
'Генерирует .env: пароли PostgreSQL, RabbitMQ, MinIO, JWT-секреты, DATA_ENCRYPTION_KEY',
'Создаёт docker-compose.override.yml — привязка портов к 127.0.0.1',
'Устанавливает Nginx и Certbot, настраивает UFW (80, 443)',
'Генерирует конфиги /etc/nginx/sites-available/lendry-id-*.conf',
'Получает SSL через certbot (webroot), включает certbot.timer',
'Запускает docker compose up -d --build и ждёт health сервисов',
'При смене доменов пересобирает frontend и docs с новыми NEXT_PUBLIC_* URL'
]
},
{
type: 'callout',
variant: 'warning',
title: 'Секреты',
text: 'Файл .env не коммитьте в git. Сохраните его в надёжном месте после первой установки. FORCE_ENV=1 генерирует новые секреты и сбрасывает старые.'
}
]
},
{
id: 'clone-config',
title: '8. Переменные .env (генерируются автоматически)',
blocks: [
{
type: 'paragraph',
text: 'install.sh создаёт и обновляет .env на основе .env.example. При ручном редактировании не меняйте секреты без необходимости — для смены доменов используйте ./install.sh --configure-domains.'
},
{
type: 'code',
language: 'bash',
title: 'Ключевые переменные .env',
code: `# Домены (без протокола)
DOMAIN_API=api.id.example.ru
DOMAIN_FRONTEND=id.example.ru
DOMAIN_DOCS=docs.example.ru
DOMAIN_WS=
# Публичные URL (скрипт подставляет https://)
PUBLIC_API_URL=https://api.id.example.ru
PUBLIC_FRONTEND_URL=https://id.example.ru
PUBLIC_DOCS_URL=https://docs.example.ru
PUBLIC_WS_URL=wss://api.id.example.ru/ws
USE_NGINX_SSL=true
CERTBOT_EMAIL=admin@example.ru
INSTALL_MODE=production
# Секреты — генерируются install.sh
JWT_ACCESS_SECRET=...
JWT_REFRESH_SECRET=...
DATA_ENCRYPTION_KEY=...
POSTGRES_PASSWORD=...
RABBITMQ_DEFAULT_PASS=...
MINIO_ROOT_PASSWORD=...`
}
]
},
{
id: 'env-services',
title: '9. Переменные окружения сервисов',
blocks: [
{
type: 'table',
headers: ['Сервис', 'Ключевые переменные'],
rows: [
['sso-core', 'DATABASE_URL, JWT_*_SECRET, DATA_ENCRYPTION_KEY, REDIS_URL, RABBITMQ_URL, MINIO_*, LDAP_AUTH_URL, PUBLIC_API_URL'],
['api-gateway', 'SSO_CORE_GRPC_URL, JWT_ACCESS_SECRET, PUBLIC_API_URL, MINIO_*'],
['frontend', 'NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL (build args + runtime)'],
['docs', 'NEXT_PUBLIC_API_URL, INTERNAL_API_URL'],
['media-ws', 'REDIS_ADDR, RABBITMQ_URL, JWT_ACCESS_SECRET'],
['ldap-auth', 'PORT=8086']
]
}
]
},
{
id: 'after-install',
title: '10. После установки',
blocks: [
{
type: 'table',
headers: ['Ресурс', 'URL (production)'],
rows: [
['API / Swagger', 'https://<DOMAIN_API>/api'],
['Frontend', 'https://<DOMAIN_FRONTEND>'],
['Документация', 'https://<DOMAIN_DOCS>/docs'],
['WebSocket', 'wss://<DOMAIN_API>/ws или wss://<DOMAIN_WS>/ws'],
['MinIO Console', 'только localhost:9001 (если не задан домен)'],
['RabbitMQ UI', 'только localhost:15672']
]
},
{
type: 'callout',
variant: 'tip',
title: 'Первый пользователь',
text: 'Первый зарегистрированный пользователь автоматически становится супер-администратором. Название проекта (PROJECT_NAME) и LDAP настраиваются в админке: /admin/settings.'
},
{
type: 'code',
language: 'bash',
title: 'Полезные команды',
code: `docker compose ps
docker compose logs -f api-gateway sso-core
./install.sh --status
./install.sh --renew-ssl`
}
]
},
{
id: 'migrations',
title: '11. Миграции и seed',
blocks: [
{
type: 'code',
language: 'bash',
code: `# Prisma migrate внутри контейнера sso-core
docker compose exec sso-core npx prisma migrate deploy
# Seed системных настроек (PROJECT_NAME, LDAP_*, PIN_*)
docker compose restart sso-core`
},
{
type: 'callout',
variant: 'info',
title: 'SystemSetting',
text: 'Название проекта (PROJECT_NAME), tagline и LDAP-параметры настраиваются в админке. Frontend и docs подтягивают PROJECT_NAME через GET /settings/public.'
}
]
},
{
id: 'backup',
title: '12. Резервное копирование',
blocks: [
{
type: 'list',
items: [
'PostgreSQL: pg_dump lendry_id ежедневно',
'MinIO: mc mirror или snapshot volume minio_data',
'Redis: RDB/AOF snapshots при необходимости сохранения сессий',
'Секреты .env — хранить в vault, не в репозитории'
]
},
{
type: 'code',
language: 'bash',
code: `docker compose exec postgres pg_dump -U lendry lendry_id > backup-$(date +%F).sql`
}
]
},
{
id: 'monitoring',
title: '13. Мониторинг и обновления',
blocks: [
{
type: 'list',
items: [
'Health: GET /health на api-gateway и sso-core',
'Логи: docker compose logs -f --tail=200',
'Обновление кода: git pull && ./install.sh --restart',
'Смена доменов: ./install.sh --configure-domains',
'SSL: ./install.sh --renew-ssl или автоматически через certbot.timer'
]
}
]
}
]
},
{
slug: 'authentication',
title: 'Аутентификация',
description: 'Способы входа: TOTP (Google Authenticator), OTP, пароль, LDAP, PIN и refresh-сессии.',
sections: [
{
id: 'flows',
title: 'Сценарии входа',
blocks: [
{
type: 'table',
headers: ['Сценарий', 'Endpoint', 'Описание'],
rows: [
['Identifier-first', 'POST /auth/identify', 'Проверка пользователя, isTotpEnabled, otpChannels и альтернативных способов'],
['TOTP (основной)', 'POST /auth/totp/begin + verify', 'Вход через Google Authenticator, если аутентификатор подключён'],
['Passwordless OTP (альтернатива)', 'POST /auth/otp/send + verify', '6-значный код на почту/телефон вместо TOTP'],
['Пароль', 'POST /auth/login/password', 'Альтернативный вход по паролю'],
['LDAP/LDAPS', 'POST /auth/ldap/login', 'Корпоративный вход (требует LDAP_ENABLED)'],
['PIN', 'POST /auth/pin/verify', 'Разблокировка сессии после входа с PIN']
]
}
]
},
{
id: 'totp-login',
title: 'Вход через приложение-аутентификатор (TOTP)',
blocks: [
{
type: 'paragraph',
text: 'Если пользователь подключил Google Authenticator (или аналог) в разделе «Безопасность», код из приложения становится основным способом входа вместо SMS/email OTP. SMS и email используются только как альтернатива через «Другой способ входа».'
},
{
type: 'list',
items: [
'1. POST /auth/identify с почтой или телефоном — в ответе isTotpEnabled: true и otpChannels (доступные каналы для альтернативного OTP).',
'2. POST /auth/totp/begin — создаёт challenge (totpChallengeToken, TTL 5 минут). SMS/email на этом шаге не отправляются.',
'3. Пользователь вводит 6-значный код из приложения-аутентификатора.',
'4. POST /auth/totp/verify с totpChallengeToken и code — выдаётся JWT и refresh token (далее при необходимости PIN).'
]
},
{
type: 'callout',
variant: 'tip',
title: 'TOTP и SMS/email — альтернативы',
text: 'Код из аутентификатора и код из SMS/email не комбинируются: это два разных способа пройти один и тот же шаг входа. После успешного OTP verify TOTP повторно не запрашивается.'
},
{
type: 'table',
headers: ['Ситуация', 'Поведение UI / API'],
rows: [
['Аутентификатор подключён', 'После identify сразу экран TOTP (totp/begin), без автоматической отправки SMS/email'],
['Альтернатива: код на почту/телефон', 'POST /auth/otp/send с channel: email или phone, затем POST /auth/otp/verify'],
['Привязаны и почта, и телефон', 'Пользователь выбирает канал (otpChannels) перед отправкой OTP'],
['Только почта или только телефон', 'OTP отправляется сразу на единственный доступный канал'],
['Альтернатива: пароль', 'POST /auth/login/password — без дополнительного TOTP'],
['Включён PIN', 'После TOTP или OTP — POST /auth/pin/verify для полной сессии']
]
},
{
type: 'code',
title: 'POST /auth/identify — фрагмент ответа',
language: 'json',
code: `{
"exists": true,
"hasPassword": true,
"isPinEnabled": false,
"isTotpEnabled": true,
"otpChannels": [
{ "channel": "email", "masked": "u***@example.com" },
{ "channel": "phone", "masked": "+7********42" }
],
"methods": [
{ "kind": "password", "channel": "password", "masked": "Пароль" }
]
}`
},
{
type: 'code',
title: 'Начать вход по TOTP',
language: 'bash',
code: `curl -X POST http://localhost:3000/auth/totp/begin \\
-H "Content-Type: application/json" \\
-d '{
"recipient": "user@example.com",
"fingerprint": "device-fingerprint-uuid",
"deviceName": "Chrome на Windows",
"deviceType": "WEB"
}'
# Ответ: { "totpChallengeToken": "eyJ..." }`
},
{
type: 'code',
title: 'Подтвердить код аутентификатора',
language: 'bash',
code: `curl -X POST http://localhost:3000/auth/totp/verify \\
-H "Content-Type: application/json" \\
-d '{
"totpChallengeToken": "eyJ...",
"code": "123456"
}'
# Ответ: AuthTokens (accessToken, refreshToken, sessionId, pinVerified, user)`
},
{
type: 'code',
title: 'Альтернатива: OTP на выбранный канал',
language: 'bash',
code: `# Отправить код (channel: email | phone | backupEmail | backupPhone)
curl -X POST http://localhost:3000/auth/otp/send \\
-H "Content-Type: application/json" \\
-d '{
"recipient": "user@example.com",
"channel": "phone"
}'
# Подтвердить OTP — сразу выдаёт сессию, без TOTP
curl -X POST http://localhost:3000/auth/otp/verify \\
-H "Content-Type: application/json" \\
-d '{
"recipient": "user@example.com",
"code": "654321",
"fingerprint": "device-fingerprint-uuid",
"deviceName": "Chrome на Windows",
"deviceType": "WEB"
}'`
}
]
},
{
id: 'totp-setup',
title: 'Подключение аутентификатора',
blocks: [
{
type: 'paragraph',
text: 'Настройка выполняется в личном кабинете: Безопасность → Дополнительная защита → Подключить аутентификатор. Название в Google Authenticator берётся из SystemSetting PROJECT_NAME (не захардкожено).'
},
{
type: 'list',
items: [
'GET /security/users/{userId}/totp/status — проверка, включён ли TOTP',
'POST /security/users/{userId}/totp/setup — QR-код (otpauthUrl) и секретный ключ',
'POST /security/users/{userId}/totp/enable — подтверждение первого кода и активация',
'POST /security/users/{userId}/totp/disable — отключение с проверкой текущего кода'
]
},
{
type: 'callout',
variant: 'info',
title: 'Повторная настройка',
text: 'Если настройка начата, но не завершена, повторный вызов setup возвращает тот же секрет — не нужно сканировать новый QR, пока не истёк незавершённый challenge.'
}
]
},
{
id: 'login-example',
title: 'Примеры входа по паролю',
blocks: [
{
type: 'paragraph',
text: 'Endpoint POST /auth/login/password принимает login (почта, телефон или username), password и метаданные устройства. Ниже — готовые примеры на разных языках.'
},
{ type: 'auth-login-examples' },
{
type: 'callout',
variant: 'info',
title: 'PIN-блокировка',
text: 'Если у пользователя включён PIN, ответ содержит requiresPin=true и ограниченную сессию. Полный JWT выдаётся после POST /auth/pin/verify.'
}
]
}
]
},
{
slug: 'oauth',
title: 'OAuth 2.0 / OIDC',
description: 'Стандартный OpenID Connect (client_id, redirect_uri, PKCE), Discovery, token endpoint и примеры для PHP, Python, Node.js.',
sections: [
{
id: 'register-app',
title: 'Регистрация OAuth-приложения',
blocks: [
{
type: 'list',
items: [
'Войдите как супер-администратор в админ-панель',
'Перейдите в RBAC → OAuth приложения',
'Создайте клиент: name, redirectUris, scopes (openid, profile, email)',
'Confidential-клиент получит client_secret один раз — сохраните его'
]
}
]
},
{
id: 'flow',
title: 'Authorization Code Flow',
blocks: [
{
type: 'paragraph',
text: 'Lendry ID поддерживает стандартный OpenID Connect (RFC 6749 / OIDC): client_id, redirect_uri, response_type=code, scope, state, PKCE (code_challenge). Параметр userId не требуется — IdP сам определяет пользователя после входа и экрана «Разрешить доступ».'
},
{
type: 'list',
items: [
'1) Ваше приложение перенаправляет браузер на GET /oauth/authorize с client_id, redirect_uri, response_type=code, scope',
'2) Пользователь входит в Lendry ID (если нужно) и подтверждает доступ',
'3) IdP делает HTTP 302 на redirect_uri?code=...&state=...',
'4) Backend обменивает code через POST /oauth/token (form-urlencoded: grant_type, client_id, client_secret, redirect_uri)',
'5) Ответ: access_token, id_token, refresh_token. Профиль: GET /oauth/userinfo'
]
},
{
type: 'callout',
variant: 'info',
title: 'OpenID Connect Discovery',
text: 'Метаданные: GET {PUBLIC_API_URL}/.well-known/openid-configuration. В PHP (jumbojett/openid-connect-php), Grafana, Authentik и других OIDC-клиентах укажите issuer = PUBLIC_API_URL. Дорабатывать OidcProvider.php под нестандартные параметры не нужно.'
},
{
type: 'callout',
variant: 'tip',
title: 'Legacy camelCase',
text: 'Для внутренних интеграций по-прежнему поддерживаются clientId, redirectUri, userId (camelCase). Внешним приложениям используйте только стандарт OIDC.'
},
{
type: 'callout',
variant: 'warning',
title: 'Безопасность',
text: 'client_secret никогда не храните во frontend. Обмен code → token выполняйте только на сервере.'
}
]
},
{
id: 'endpoints',
title: 'Endpoints и Discovery',
blocks: [
{
type: 'paragraph',
text: 'Issuer и все endpoints берутся из PUBLIC_API_URL (настройка в админ-панели). Локально по умолчанию: http://localhost:3000. При деплое через frontend same-origin: https://ваш-домен/idp-api.'
},
{
type: 'table',
headers: ['Endpoint', 'URL'],
rows: [
['Issuer / Discovery', '{PUBLIC_API_URL}/.well-known/openid-configuration'],
['Authorization', '{PUBLIC_API_URL}/oauth/authorize'],
['Token', '{PUBLIC_API_URL}/oauth/token'],
['UserInfo', '{PUBLIC_API_URL}/oauth/userinfo'],
['JWKS', '{PUBLIC_API_URL}/.well-known/jwks.json']
]
},
{
type: 'callout',
variant: 'warning',
title: 'Issuer ≠ frontend URL',
text: 'В Grafana, PHP OIDC-клиентах и других интеграциях указывайте issuer = PUBLIC_API_URL (базовый URL API), а не адрес Next.js frontend (порт 3002).'
}
]
},
{
id: 'authorize-params',
title: 'GET /oauth/authorize — параметры',
blocks: [
{
type: 'table',
headers: ['Параметр', 'Обязательный', 'Описание'],
rows: [
['client_id', 'Да', 'Client ID из админки OAuth-приложений'],
['redirect_uri', 'Да', 'Должен точно совпадать с URI, зарегистрированным у клиента'],
['response_type', 'Да', 'Только code'],
['scope', 'Да', 'Например: openid profile email'],
['state', 'Рекомендуется', 'CSRF-защита клиента'],
['code_challenge', 'Опционально', 'PKCE S256 (публичные клиенты)'],
['code_challenge_method', 'С PKCE', 'S256 или plain']
]
},
{
type: 'code',
language: 'text',
title: 'Пример authorize URL (стандарт OIDC)',
code: `https://id.lendry.ru/idp-api/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
&response_type=code
&scope=openid%20profile%20email
&state=random-state-value`
},
{
type: 'callout',
variant: 'info',
title: 'userId не нужен',
text: 'Параметр userId (и legacy clientId/redirectUri) поддерживается только для внутренних интеграций. Внешние OIDC-клиенты (PHP OidcProvider, Grafana, Keycloak) передают только стандартные поля — IdP сам определяет пользователя после входа и экрана «Разрешить доступ».'
}
]
},
{
id: 'token-endpoint',
title: 'POST /oauth/token',
blocks: [
{
type: 'paragraph',
text: 'Обмен authorization code на токены выполняется только на backend. Поддерживаются application/x-www-form-urlencoded (рекомендуется для OIDC) и JSON.'
},
{
type: 'code',
language: 'bash',
title: 'Authorization Code Grant (form-urlencoded)',
code: `curl -X POST https://id.lendry.ru/idp-api/oauth/token \\
-H "Content-Type: application/x-www-form-urlencoded" \\
-d "grant_type=authorization_code" \\
-d "code=AUTHORIZATION_CODE" \\
-d "client_id=YOUR_CLIENT_ID" \\
-d "client_secret=YOUR_CLIENT_SECRET" \\
-d "redirect_uri=https://app.example.com/oauth/callback"`
},
{
type: 'code',
language: 'json',
title: 'Ответ token endpoint (RFC 6749)',
code: `{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "eyJ...",
"id_token": "eyJ..."
}`
},
{
type: 'list',
items: [
'grant_type=refresh_token — обновление access token',
'Authorization: Basic base64(client_id:client_secret) — альтернатива client_secret в теле',
'GET /oauth/userinfo с заголовком Authorization: Bearer {access_token}'
]
}
]
},
{
id: 'external-apps',
title: 'Подключение внешних приложений',
blocks: [
{
type: 'paragraph',
text: 'Стандартные OIDC-клиенты работают без кастомизации. Укажите issuer из Discovery и зарегистрированные client_id / redirect_uri.'
},
{
type: 'table',
headers: ['Платформа', 'Настройка'],
rows: [
['PHP (jumbojett/openid-connect-php)', 'new OpenIDConnectClient($issuer, $clientId, $clientSecret) — библиотека сама вызывает Discovery'],
['Grafana', 'Auth → Generic OAuth → Auth URL / Token URL / API URL из Discovery'],
['Authentik / Keycloak (как client)', 'Issuer URL = PUBLIC_API_URL'],
['Любой OIDC RP', 'GET /.well-known/openid-configuration → автоконфигурация endpoints']
]
},
{
type: 'code',
language: 'php',
title: 'PHP — без доработки OidcProvider',
code: `<?php
require 'vendor/autoload.php';
use Jumbojett\\OpenIDConnectClient;
$issuer = 'https://id.lendry.ru/idp-api'; // PUBLIC_API_URL
$oidc = new OpenIDConnectClient(
$issuer,
getenv('OAUTH_CLIENT_ID'),
getenv('OAUTH_CLIENT_SECRET')
);
$oidc->setRedirectURL('https://app.example.com/oauth/callback');
$oidc->addScope(['openid', 'profile', 'email']);
$oidc->authenticate(); // client_id, redirect_uri, response_type=code — автоматически`
},
{
type: 'callout',
variant: 'tip',
title: 'Redirect URI',
text: 'redirect_uri в запросе authorize и token должен байт-в-байт совпадать с одним из URI, указанных при создании OAuth-клиента в админке Lendry ID (включая протокол, путь и завершающий слэш).'
}
]
},
{
id: 'examples',
title: 'Примеры интеграции',
blocks: [{ type: 'oauth-examples' }]
},
{
id: 'scopes',
title: 'Scopes',
blocks: [
{
type: 'table',
headers: ['Scope', 'Доступ'],
rows: [
['openid', 'Идентификатор пользователя (sub)'],
['profile', 'displayName, avatar, bio'],
['email', 'Основная и резервная почта'],
['phone', 'Телефоны пользователя']
]
}
]
}
]
},
{
slug: 'ldap',
title: 'LDAP / LDAPS',
description: 'Корпоративный вход через Active Directory.',
sections: [
{
id: 'setup',
title: 'Настройка в админке',
blocks: [
{
type: 'table',
headers: ['Ключ SystemSetting', 'Описание'],
rows: [
['LDAP_ENABLED', 'Включить кнопку «Войти через LDAP»'],
['LDAP_USE_LDAPS', 'LDAPS на порту 636'],
['LDAP_HOST', 'DC-1.domain.local,DC-2.domain.local'],
['LDAP_BIND_USERNAME', 'Логин сервисной УЗ (mvkadmin)'],
['LDAP_BASE_DN', 'OU=Users,DC=domain,DC=local'],
['LDAP_BIND_PASSWORD', 'Пароль сервисной УЗ']
]
}
]
},
{
id: 'login',
title: 'Примеры LDAP-входа',
blocks: [
{
type: 'paragraph',
text: 'Endpoint POST /auth/ldap/login доступен при включённой настройке LDAP_ENABLED.'
},
{ type: 'auth-ldap-examples' }
]
}
]
},
{
slug: 'sessions',
title: 'Сессии, PIN и удаление аккаунта',
description: 'Управление устройствами, PIN-блокировка, отзыв сессий и отложенное удаление профиля.',
sections: [
{
id: 'totp',
title: 'TOTP (Google Authenticator)',
blocks: [
{
type: 'paragraph',
text: 'Двухфакторная аутентификация через TOTP заменяет SMS/email OTP при входе. Управление устройствами и отзыв сессий — через те же endpoints, что описаны ниже.'
},
{
type: 'list',
items: [
'GET /security/users/{userId}/totp/status',
'POST /security/users/{userId}/totp/setup | enable | disable',
'POST /auth/totp/begin и POST /auth/totp/verify — вход с аутентификатором'
]
}
]
},
{
id: 'pin',
title: 'PIN-код',
blocks: [
{
type: 'paragraph',
text: 'PIN хранится как bcrypt hash. Таймаут блокировки читается из SystemSetting PIN_LOCK_TIMEOUT_MINUTES — значение не захардкожено во frontend.'
},
{
type: 'list',
items: [
'PIN_DELETE_GRACE_MINUTES — задержка перед окончательным удалением PIN после запроса',
'PIN_REQUIRE_ON_DELETE — требовать текущий PIN при запросе удаления защиты'
]
}
]
},
{
id: 'account-deletion',
title: 'Удаление аккаунта',
blocks: [
{
type: 'paragraph',
text: 'Пользователь может запланировать удаление профиля в личном кабинете: раздел «Данные» (/data) → «Удалить профиль». Аккаунт не удаляется мгновенно — начинается период ожидания, настраиваемый администратором.'
},
{
type: 'table',
headers: ['Ключ SystemSetting', 'Описание', 'По умолчанию'],
rows: [
['ACCOUNT_DELETE_GRACE_DAYS', 'Через сколько дней после запроса окончательно удалить аккаунт', '30']
]
},
{
type: 'list',
items: [
'POST /profile/users/{userId}/self-delete — запланировать удаление (только свой профиль)',
'GET /profile/users/{userId}/self-delete/status — дата окончательного удаления и статус pending',
'POST /profile/users/{userId}/self-delete/cancel — отменить запрос до истечения срока',
'До истечения срока пользователь может входить и пользоваться сервисом как обычно',
'На странице /data отображается предупреждение с датой удаления и кнопкой отмены'
]
},
{
type: 'callout',
variant: 'warning',
title: 'Что происходит при финализации',
text: 'Фоновый планировщик sso-core (каждые 5 минут) находит аккаунты с истёкшим сроком ожидания и выполняет soft-delete: анонимизация email/телефона/username, отзыв сессий, снятие ролей. Семьи, где пользователь — создатель, удаляются полностью (участники, чаты, медиа). Из остальных семей пользователь исключается.'
}
]
},
{
id: 'devices',
title: 'Устройства и сессии',
blocks: [
{
type: 'list',
items: [
'GET /security/users/{userId}/devices — список устройств',
'POST /security/users/{userId}/sessions/{sessionId}/revoke — выход с устройства',
'POST /security/users/{userId}/revoke-all-sessions — выход везде'
]
}
]
}
]
},
{
slug: 'family-chat',
title: 'Семья и чат',
description:
'Семейные группы, автоматические личные и бот-чаты, секретные E2E-чаты, SVG-смайлики, BotFather и realtime через WebSocket.',
sections: [
{
id: 'family',
title: 'Семейные группы',
blocks: [
{
type: 'paragraph',
text: 'Пользователь создаёт семью, приглашает участников по email/телефону/логину или через поиск в интерфейсе. Лимит участников задаётся в SystemSetting MAX_FAMILY_MEMBERS (по умолчанию 6). При создании семьи автоматически создаётся общий чат «Общий чат».'
},
{
type: 'list',
items: [
'POST /family/groups — создать семью',
'GET /family/users/{userId}/groups — список семей пользователя',
'PATCH /family/groups/{groupId} — переименовать (только участники, название — создатель)',
'POST /family/groups/{groupId}/invites — отправить приглашение',
'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей для приглашения'
]
}
]
},
{
id: 'family-leave',
title: 'Выход и исключение участников',
blocks: [
{
type: 'paragraph',
text: 'Участник может выйти из семьи самостоятельно; создатель семьи может исключить любого участника, кроме себя. При выходе или исключении пользователь удаляется из всех чатов этой семьи.'
},
{
type: 'list',
items: [
'DELETE /family/members/{memberId} — выход (если memberId свой) или исключение (если запрос от создателя семьи)',
'Создателя семьи (role: owner) удалить через этот endpoint нельзя — только удалить всю семью целиком'
]
}
]
},
{
id: 'family-delete',
title: 'Удаление семьи',
blocks: [
{
type: 'paragraph',
text: 'Только создатель семьи может полностью удалить группу. В интерфейсе: страница семьи → «Участники семьи» → «Удалить семью». Действие необратимо.'
},
{
type: 'list',
items: [
'DELETE /family/groups/{groupId} — удалить семью (только ownerId === текущий пользователь)',
'Все участники исключаются из семьи',
'Все приглашения (FamilyInvite) удаляются',
'Все чаты семьи (ChatRoom), сообщения, опросы и голоса удаляются каскадом',
'Медиа в MinIO (аватар семьи, аватары чатов, вложения сообщений) удаляются best-effort',
'Остальным участникам отправляется уведомление family_group_deleted и событие WebSocket'
]
},
{
type: 'callout',
variant: 'warning',
title: 'Без передачи владения',
text: 'Перед удалением семьи нельзя «передать» роль создателя другому участнику — только полное удаление группы или выход участников по отдельности.'
}
]
},
{
id: 'room-types',
title: 'Типы чатов семьи',
blocks: [
{
type: 'paragraph',
text: 'При каждом запросе списка чатов (GET /chat/groups/{groupId}/rooms) сервер вызывает syncFamilyChats: автоматически создаёт недостающие личные и бот-чаты между участниками семьи и мигрирует устаревшие двухместные GROUP в DIRECT или BOT. Секретные E2E-чаты создаются только явно через POST /chat/groups/{groupId}/e2e-rooms.'
},
{
type: 'table',
headers: ['type', 'Описание', 'Создание', 'Шифрование'],
rows: [
['GENERAL', 'Общий чат семьи «Общий чат»', 'При создании семьи', 'Нет'],
['GROUP', 'Групповой чат (3+ участника)', 'POST /chat/groups/{groupId}/rooms', 'Нет'],
['DIRECT', 'Обычный личный чат между двумя людьми', 'Автоматически при добавлении участника', 'Нет (isE2E: false)'],
['BOT', 'Чат с Telegram-ботом участника семьи', 'Автоматически при добавлении бота в семью', 'Нет; сообщения через Bot API'],
['E2E', 'Секретный end-to-end чат', 'POST /chat/groups/{groupId}/e2e-rooms', 'Да; isEncrypted: true обязателен']
]
},
{
type: 'callout',
variant: 'info',
title: 'Поля ChatRoom',
text: 'peerUserId — id собеседника (DIRECT, BOT, E2E). botUsername — @username бота (только BOT). isE2E — true только для type: E2E. Обычные DIRECT всегда isE2E: false.'
}
]
},
{
id: 'chat-rest',
title: 'REST API чата',
blocks: [
{
type: 'list',
items: [
'GET /chat/groups/{groupId}/rooms — список чатов (с автосинхронизацией)',
'POST /chat/groups/{groupId}/rooms — создать групповой чат (минимум 3 участника)',
'POST /chat/groups/{groupId}/e2e-rooms — создать секретный E2E-чат',
'GET /chat/rooms/{roomId}/messages?limit=50&beforeMessageId=... — история',
'POST /chat/rooms/{roomId}/messages — отправить сообщение',
'PATCH /chat/messages/{messageId} — редактировать текст',
'DELETE /chat/messages/{messageId} — удалить сообщение',
'POST /chat/messages/{messageId}/vote — голос в опросе',
'POST /chat/rooms/{roomId}/read — отметить прочитанным',
'POST /chat/rooms/{roomId}/mute — { "muted": true }',
'POST /media/chat/{roomId}/media/upload-url — presigned URL для вложений'
]
},
{
type: 'code',
language: 'json',
title: 'Пример ChatRoom в ответе',
code: `{
"id": "clx...",
"groupId": "clx...",
"type": "E2E",
"name": "Анна",
"peerUserId": "clx-peer...",
"botUsername": null,
"isE2E": true,
"unreadCount": 0,
"lastMessage": { "type": "TEXT", "content": "{\\"v\\":1,...}", "isEncrypted": true }
}`
},
{
type: 'code',
language: 'json',
title: 'POST /chat/rooms/{roomId}/messages — тело запроса',
code: `{
"type": "TEXT",
"content": "Привет!",
"isEncrypted": false,
"replyToId": null,
"storageKey": null,
"mimeType": null,
"metadataJson": null,
"poll": null
}`
},
{
type: 'paragraph',
text: 'Типы сообщений: TEXT, IMAGE, AUDIO, VOICE, FILE, EMOJI, POLL. Для медиа сначала загрузите файл через upload-url, затем передайте storageKey (должен начинаться с chat/{roomId}/). Поле isEncrypted: true обязательно для комнат type: E2E.'
}
]
},
{
id: 'bot-family-chat',
title: 'Чаты с ботами в семье',
blocks: [
{
type: 'paragraph',
text: 'Если участник семьи — Telegram-бот (у User есть linkedBotId), для каждого человека в семье автоматически создаётся комната type: BOT. Отправка сообщений через REST /chat/rooms/{roomId}/messages для BOT запрещена — используйте Bot API inbound.'
},
{
type: 'list',
items: [
'GET /bots/by-username/{botRef}/messages — история чата с ботом (в ответе composerMenuButtonJson и composerWebAppUrl)',
'POST /bots/by-username/{botRef}/messages — { "text": "..." } → Update боту (webhook/getUpdates)',
'POST /bots/by-username/{botRef}/callback — { "messageId", "callbackData" } → callback_query',
'WebSocket: bot_message, bot_message_edited, bot_callback_answer, bot_menu_button_updated'
]
},
{
type: 'code',
language: 'bash',
title: 'Написать боту из семейного чата',
code: `curl -s -X POST http://localhost:3000/bots/by-username/my_service_bot/messages \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"text":"/start"}'`
},
{
type: 'callout',
variant: 'tip',
title: 'Mini App управления ботом',
text: 'Mini App для управления ботом открывается владельцем через шестерёнку в заголовке чата (не через кнопку меню). Глобальная кнопка меню (Web App) настраивается через BotFather (/setmenubutton) или setChatMenuButton Bot API. Legacy: PATCH /bots/{botId}/web-app с { "webAppUrl": "https://..." }. Подробнее — раздел «Telegram Bot API».'
}
]
},
{
id: 'botfather-family',
title: 'BotFather в семье',
blocks: [
{
type: 'paragraph',
text: 'Системный бот BotFather можно пригласить в семейную группу, чтобы создавать и управлять Telegram-ботами прямо из семейного чата (команды /newbot, /mybots, настройка профиля бота и inline-кнопки Mini App). Боты не подтверждают приглашение вручную: система принимает его автоматически.'
},
{
type: 'list',
items: [
'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей, BotFather, GetMyIdBot и ботов других владельцев',
'POST /family/groups/{groupId}/invites — пригласить найденного пользователя или бота ({ "inviteeUserId": "..." })',
'Для бота приглашение сразу получает статус ACCEPTED, после чего появляется BOT-чат для участников',
'Команды профиля в чате BotFather: /setdescription, /setabouttext, /setuserpic, /setmenubutton'
]
}
]
},
{
id: 'e2e-chat',
title: 'Секретные E2E-чаты',
blocks: [
{
type: 'paragraph',
text: 'E2E-чаты (type: E2E) — отдельные комнаты помимо обычных DIRECT. Сервер хранит только зашифрованный ciphertext; расшифровка выполняется на клиенте. Опросы (POLL) в E2E недоступны.'
},
{
type: 'list',
items: [
'1. Сгенерировать ECDH P-256 key pair на клиенте (Web Crypto API)',
'2. PATCH /profile/users/{userId}/e2e-public-key — опубликовать publicKey (SPKI base64)',
'3. GET /profile/users/{peerUserId}/e2e-public-key — получить ключ собеседника',
'4. POST /chat/groups/{groupId}/e2e-rooms — { "peerUserId": "..." }',
'5. Шифровать payload и отправлять POST /chat/rooms/{roomId}/messages с isEncrypted: true'
]
},
{
type: 'callout',
variant: 'warning',
title: 'Приватный ключ',
text: 'Приватный ключ хранится только на клиенте (localStorage в веб-приложении). Сервер не может расшифровать сообщения. При потере ключа история E2E-чата становится нечитаемой.'
},
{
type: 'paragraph',
text: 'Алгоритм (как в apps/frontend/lib/e2e-crypto.ts): ECDH P-256 → derive AES-256-GCM. Текстовые сообщения: JSON envelope { v: 1, iv, ciphertext } в поле content. Бинарные медиа: IV (12 байт) + ciphertext, файл загружается как application/octet-stream.'
},
{
type: 'code',
language: 'json',
title: 'Зашифрованный payload внутри content (после расшифровки)',
code: `{
"kind": "text",
"text": "Секретное сообщение"
}
{
"kind": "emoji",
"emojiId": "e-grinning"
}
{
"kind": "image",
"fileName": "photo.jpg",
"mimeType": "image/jpeg",
"fileSize": 102400
}`
},
{
type: 'code',
language: 'bash',
title: 'Создание E2E-чата',
code: `# Опубликовать свой ключ
curl -s -X PATCH http://localhost:3000/profile/users/$USER_ID/e2e-public-key \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"publicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."}'
# Создать секретный чат
curl -s -X POST http://localhost:3000/chat/groups/$GROUP_ID/e2e-rooms \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"peerUserId":"clx-peer-user-id"}'`
},
{
type: 'code',
language: 'json',
title: 'Отправка зашифрованного текста',
code: `{
"type": "TEXT",
"content": "{\\"v\\":1,\\"iv\\":\\"...\\",\\"ciphertext\\":\\"...\\"}",
"isEncrypted": true
}`
}
]
},
{
id: 'emojis',
title: 'SVG-смайлики',
blocks: [
{
type: 'paragraph',
text: 'Вместо системных Unicode-emoji используются SVG-спрайты (как sticker packs в Telegram). Сообщение типа EMOJI хранит id смайлика в content, например e-grinning.'
},
{
type: 'list',
items: [
'Отправка: POST /chat/rooms/{roomId}/messages с { "type": "EMOJI", "content": "e-grinning" }',
'Спрайт: {FRONTEND_URL}/emojis/lendry-emojis.svg#e-grinning',
'Каталог: 121 смайлик в 8 категориях (smileys, gestures, hearts, animals, food, travel, objects, symbols)',
'Id формата e-{slug}, например e-thumbs-up, e-red-heart, e-pizza',
'В E2E: зашифровать payload { "kind": "emoji", "emojiId": "e-grinning" } и отправить type: TEXT или EMOJI с isEncrypted: true'
]
},
{
type: 'code',
language: 'html',
title: 'Отображение смайлика в UI',
code: `<svg class="h-8 w-8" aria-hidden="true">
<use href="https://id.lendry.ru/emojis/lendry-emojis.svg#e-grinning" />
</svg>`
},
{
type: 'callout',
variant: 'info',
title: 'Справочник id',
text: 'Полный список id и категорий — apps/frontend/lib/emoji-catalog.ts (EMOJI_DEFINITIONS). Спрайт генерируется скриптом scripts/generate-emoji-sprite.mjs.'
}
]
},
{
id: 'chat-websocket',
title: 'WebSocket и realtime',
blocks: [
{
type: 'list',
items: [
'WebSocket: ws://localhost:8085/ws с JWT в query (?token=...) или заголовке Authorization',
'События чата: chat_message, chat_message_updated, chat_message_deleted',
'События бота: bot_message, bot_message_edited, bot_callback_answer, bot_menu_button_updated',
'Семья: family_group_deleted при удалении группы',
'Медиа: presigned upload через /media/chat/{roomId}/media/upload-url, просмотр через /media/stream/{token} с Authorization'
]
}
]
}
]
},
{
slug: 'bot-api',
title: 'Telegram Bot API',
description:
'Совместимый с Telegram Bot API прокси: Telegraf, node-telegram-bot-api, профиль бота, кнопка меню (setChatMenuButton), BotFather и Mini Apps.',
sections: [
{
id: 'overview',
title: 'Обзор',
blocks: [
{
type: 'paragraph',
text: 'Lendry ID включает собственный Bot API engine — drop-in замену api.telegram.org. Формат маршрутов, тела запросов и JSON-ответов совпадает с официальным Telegram Bot API, поэтому существующие боты работают без переписывания кода: достаточно указать apiRoot / baseApiUrl на ваш api-gateway.'
},
{
type: 'callout',
variant: 'info',
title: 'Базовый URL',
text: 'Локально: http://localhost:3000/bot{token}/{method}. В production — значение PUBLIC_API_URL из админ-панели + /bot{token}/{method}. Пример: POST https://id.lendry.ru/idp-api/bot123456789:SECRET/sendMessage'
},
{
type: 'list',
items: [
'BotFather (REST + чат) — регистрация ботов, профиль, перевыпуск токена, кнопка меню',
'Telegram-совместимый endpoint — getMe, sendMessage, setChatMenuButton, клавиатуры, редактирование, callback, медиа',
'Mini Apps — HMAC-SHA256 валидация initData как в Telegram',
'Rate limiting через Redis и SystemSetting',
'Админ-панель — список всех ботов, блокировка, метрики (право bots.manage.all)'
]
}
]
},
{
id: 'botfather',
title: 'BotFather — регистрация бота',
blocks: [
{
type: 'paragraph',
text: 'Любой авторизованный пользователь Lendry ID может создать до BOT_MAX_BOTS_PER_USER ботов (по умолчанию 5). Токен генерируется в формате Telegram (123456789:secret) и возвращается только при создании или revoke — в БД хранится SHA-256 hash.'
},
{
type: 'table',
headers: ['Метод', 'Путь', 'Описание'],
rows: [
['GET', '/bots', 'Список ботов текущего пользователя'],
['POST', '/bots', 'Создать бота (name, username → username_bot)'],
['GET', '/bots/{botId}', 'Настройки бота (без полного токена, только tokenPrefix)'],
['PATCH', '/bots/{botId}', 'Изменить name / username'],
['DELETE', '/bots/{botId}', 'Удалить бота и все чаты/сообщения'],
['POST', '/bots/{botId}/revoke-token', 'Перевыпустить токен'],
['PATCH', '/bots/{botId}/web-app', 'Legacy: привязать URL Mini App (webAppUrl)'],
['GET', '/bots/by-username/{botRef}/messages', 'История чата + composerMenuButtonJson']
]
},
{
type: 'table',
headers: ['Поле BotResponse', 'Описание'],
rows: [
['description', 'Описание бота — показывается перед /start'],
['aboutText', 'Краткое описание на странице профиля бота'],
['botPicUrl', 'URL аватара бота'],
['menuButtonJson', 'Глобальная кнопка меню (Web App) в формате Telegram JSON'],
['webAppUrl', 'Legacy fallback для кнопки меню, если menuButton не задан']
]
},
{
type: 'code',
language: 'json',
title: 'POST /bots — тело запроса',
code: `{
"name": "Сервис уведомлений",
"username": "notify_service"
}`
},
{
type: 'code',
language: 'json',
title: 'Ответ при создании',
code: `{
"bot": {
"id": "uuid",
"name": "Сервис уведомлений",
"username": "notify_service_bot",
"tokenPrefix": "482910374:AbCdEf…",
"webAppUrl": null,
"description": null,
"aboutText": null,
"botPicUrl": null,
"menuButtonJson": null,
"isActive": true
},
"token": "482910374:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"
}`
},
{
type: 'callout',
variant: 'warning',
title: 'Username',
text: 'Username: 532 символа, латиница, цифры и _. Система автоматически добавляет суффикс _bot. Передавайте имя без _bot, например my_service → my_service_bot.'
}
]
},
{
id: 'botfather-chat',
title: 'BotFather — команды в чате',
blocks: [
{
type: 'paragraph',
text: 'Системный бот BotFather доступен в семейном чате после приглашения через поиск семьи. Помимо /newbot и /mybots поддерживается настройка профиля каждого вашего бота прямо из переписки. Username указывайте без суффикса _bot.'
},
{
type: 'table',
headers: ['Команда', 'Пример', 'Действие'],
rows: [
['/setdescription', '/setdescription my_service Описание перед стартом', 'Обновляет Bot.description'],
['/setabouttext', '/setabouttext my_service Кратко о боте', 'Обновляет Bot.aboutText'],
['/setuserpic', '/setuserpic my_service https://cdn.example.com/avatar.png', 'Обновляет Bot.botPicUrl'],
['/setmenubutton', '/setmenubutton my_service https://app.example.com|Открыть', 'Глобальная кнопка меню (Web App)'],
['/setmenubutton (JSON)', '/setmenubutton my_service {"type":"web_app","text":"App","web_app":{"url":"https://..."}}', 'Точный формат Telegram menu_button']
]
},
{
type: 'list',
items: [
'Команды доступны только владельцу бота',
'После обновления профиля владелец получает WebSocket bot_profile_updated',
'При смене глобальной кнопки меню все пользователи с чатами бота получают bot_menu_button_updated',
'Mini App «Создать бота» и «Управление ботом» — через inline-кнопки и /mybots'
]
},
{
type: 'callout',
variant: 'info',
title: 'Формат setmenubutton',
text: 'Поддерживаются: полный JSON Telegram ({ type: "web_app", text, web_app: { url } }), голый HTTPS URL (текст кнопки «App») или URL|Текст кнопки. Для сброса глобальной кнопки используйте Bot API setChatMenuButton без chat_id и с menu_button: { "type": "default" }.'
}
]
},
{
id: 'telegram-endpoint',
title: 'Telegram-совместимый endpoint',
blocks: [
{
type: 'paragraph',
text: 'Все методы принимаются по шаблону POST /bot{token}/{methodName} (также поддерживается GET с query-параметрами). Авторизация — только токен в URL, JWT не требуется.'
},
{
type: 'table',
headers: ['Метод Bot API', 'Статус', 'Описание'],
rows: [
['getMe', '✓', 'Информация о боте (is_bot, username, first_name)'],
['sendMessage', '✓', 'Отправка текста с InlineKeyboardMarkup / ReplyKeyboardMarkup / ReplyKeyboardRemove'],
['editMessageText', '✓', 'Редактирование текста и/или reply_markup существующего сообщения'],
['editMessageReplyMarkup', '✓', 'Изменение только клавиатуры сообщения'],
['answerCallbackQuery', '✓', 'Ответ на нажатие inline-кнопки (toast / alert)'],
['sendPhoto', '✓', 'Отправка фото по URL или file_id (базовая реализация)'],
['sendDocument', '✓', 'Отправка документа по URL или file_id (базовая реализация)'],
['setWebhook / deleteWebhook / getWebhookInfo', '✓', 'Управление webhook URL и secret_token'],
['getUpdates', '✓', 'Long polling очередь входящих Update (offset, limit, timeout)'],
['setChatMenuButton', '✓', 'Глобальная или per-chat кнопка меню (Web App)']
]
},
{
type: 'callout',
variant: 'info',
title: 'reply_markup как строка',
text: 'Библиотеки вроде node-telegram-bot-api могут передавать reply_markup JSON-строкой вместо объекта. Движок автоматически парсит строку (включая двойное кодирование) в sendMessage, editMessageText, editMessageReplyMarkup, sendPhoto и sendDocument.'
},
{
type: 'code',
language: 'json',
title: 'Успешный ответ sendMessage',
code: `{
"ok": true,
"result": {
"message_id": 1,
"from": {
"id": 1234567890,
"is_bot": true,
"first_name": "Сервис уведомлений",
"username": "notify_service_bot"
},
"chat": {
"id": 1000000000000,
"type": "private",
"first_name": "Иван",
"username": "ivan"
},
"date": 1710000000,
"text": "Привет из Bot API!"
}
}`
},
{
type: 'code',
language: 'json',
title: 'Ошибки (формат Telegram)',
code: `// 401 — неверный или заблокированный токен
{ "ok": false, "error_code": 401, "description": "Unauthorized" }
// 429 — превышен лимит запросов (BOT_API_RATE_LIMIT_PER_SECOND)
{
"ok": false,
"error_code": 429,
"description": "Too Many Requests: retry later",
"parameters": { "retry_after": 1 }
}`
},
{
type: 'callout',
variant: 'tip',
title: 'chat_id',
text: 'Для первого сообщения пользователю передайте chat_id = UUID пользователя Lendry ID. Система создаст BotChat и вернёт numeric chat.id в ответе — его можно использовать в последующих sendMessage, editMessageText и editMessageReplyMarkup.'
}
]
},
{
id: 'menu-button',
title: 'Кнопка меню (Menu Button / Web App)',
blocks: [
{
type: 'paragraph',
text: 'Кнопка меню отображается слева от поля ввода в чате с ботом (как в Telegram). Конфигурация хранится в Bot.menuButton (глобально) и в ChatMenuButton (переопределение для конкретного пользователя/чата). Frontend разрешает иерархию: локальный override → глобальный menuButton → legacy webAppUrl → BotFather create URL.'
},
{
type: 'table',
headers: ['Источник', 'Когда используется'],
rows: [
['ChatMenuButton (per-chat)', 'setChatMenuButton с chat_id — приоритет для этого пользователя'],
['Bot.menuButton (глобально)', 'setChatMenuButton без chat_id или /setmenubutton в BotFather'],
['Bot.webAppUrl (legacy)', 'Если menuButton не задан; bot-manage URL исключается из composer'],
['BotFather (системный бот)', 'Mini App «Создать бота» для @BotFather_bot']
]
},
{
type: 'code',
language: 'json',
title: 'Схема menu_button (Telegram)',
code: `{
"type": "web_app",
"text": "Открыть приложение",
"web_app": { "url": "https://app.example.com/mini" }
}
// Сброс per-chat override (вернуться к глобальной):
{ "type": "default" }`
},
{
type: 'code',
language: 'json',
title: 'POST /bot{token}/setChatMenuButton — глобальная кнопка',
code: `POST /bot{token}/setChatMenuButton
{
"menu_button": {
"type": "web_app",
"text": "Каталог",
"web_app": { "url": "https://shop.example.com/webapp" }
}
}
// Ответ:
{ "ok": true, "result": true }`
},
{
type: 'code',
language: 'json',
title: 'Per-chat override',
code: `POST /bot{token}/setChatMenuButton
{
"chat_id": 1000000000000,
"menu_button": {
"type": "web_app",
"text": "Персональное меню",
"web_app": { "url": "https://app.example.com/user/123" }
}
}
// Сброс override для чата (вернуть глобальную кнопку):
{
"chat_id": 1000000000000,
"menu_button": { "type": "default" }
}
// или omit menu_button`
},
{
type: 'code',
language: 'json',
title: 'GET /bots/by-username/{botRef}/messages — фрагмент ответа',
code: `{
"botUsername": "my_service_bot",
"botDisplayName": "Мой сервис",
"composerWebAppUrl": "https://app.example.com/webapp",
"composerMenuButtonJson": "{\\"type\\":\\"web_app\\",\\"text\\":\\"Каталог\\",\\"web_app\\":{\\"url\\":\\"https://app.example.com/webapp\\"}}",
"manageWebAppUrl": "/mini-apps/bot-manage?botId=..."
}`
},
{
type: 'callout',
variant: 'tip',
title: 'Realtime',
text: 'После setChatMenuButton клиент получает WebSocket bot_menu_button_updated с composerMenuButtonJson и composerWebAppUrl — поле ввода перерисовывается без перезагрузки страницы.'
}
]
},
{
id: 'keyboards',
title: 'Клавиатуры (Reply и Inline)',
blocks: [
{
type: 'paragraph',
text: 'Параметр reply_markup в sendMessage, editMessageText и editMessageReplyMarkup принимает те же объекты, что и официальный Telegram Bot API. Движок нормализует их во внутренний формат для рендера в мессенджере Lendry ID и сохраняет оригинальный Telegram JSON для совместимости с библиотеками.'
},
{
type: 'table',
headers: ['Telegram reply_markup', 'Назначение'],
rows: [
['InlineKeyboardMarkup', 'Кнопки под сообщением: callback_data, url, web_app'],
['ReplyKeyboardMarkup', 'Пользовательская клавиатура вместо стандартной (resize_keyboard, one_time_keyboard)'],
['ReplyKeyboardRemove', 'Скрыть reply-клавиатуру у пользователя']
]
},
{
type: 'code',
language: 'json',
title: 'sendMessage с inline-клавиатурой',
code: `POST /bot{token}/sendMessage
{
"chat_id": "USER_UUID",
"text": "Выберите действие:",
"reply_markup": {
"inline_keyboard": [
[
{ "text": "✅ Подтвердить", "callback_data": "confirm" },
{ "text": "❌ Отмена", "callback_data": "cancel" }
],
[
{ "text": "Открыть сайт", "url": "https://lendry.ru" },
{ "text": "Mini App", "web_app": { "url": "https://app.example.com" } }
]
]
}
}`
},
{
type: 'code',
language: 'json',
title: 'sendMessage с reply-клавиатурой',
code: `{
"chat_id": "1000000000000",
"text": "Отправьте контакт или выберите пункт меню:",
"reply_markup": {
"keyboard": [
[{ "text": "📞 Отправить телефон", "request_contact": true }],
[{ "text": "Меню" }, { "text": "Помощь" }]
],
"resize_keyboard": true,
"one_time_keyboard": true,
"input_field_placeholder": "Введите сообщение…"
}
}`
},
{
type: 'code',
language: 'json',
title: 'Внутренний формат (replyMarkup в WebSocket-событиях)',
code: `// kind: "inline" — кнопки с callbackData / url / webAppUrl
{
"kind": "inline",
"rows": [
[
{ "text": "✅ Подтвердить", "callbackData": "confirm" },
{ "text": "❌ Отмена", "callbackData": "cancel" }
]
]
}
// kind: "reply" — пользовательская клавиатура
{
"kind": "reply",
"rows": [[{ "text": "Меню" }]],
"resizeKeyboard": true,
"oneTimeKeyboard": true
}
// kind: "remove"
{ "kind": "remove" }`
}
]
},
{
id: 'edit-messages',
title: 'Редактирование сообщений',
blocks: [
{
type: 'paragraph',
text: 'Бот может обновлять уже отправленные сообщения. Система находит запись в BotMessage по chat_id и message_id, обновляет текст и/или reply_markup, выставляет editedAt и мгновенно уведомляет клиент мессенджера через WebSocket (событие bot_message_edited).'
},
{
type: 'table',
headers: ['Метод', 'Обязательные параметры', 'Описание'],
rows: [
['editMessageText', 'chat_id, message_id, text', 'Изменить текст; reply_markup опционален'],
['editMessageReplyMarkup', 'chat_id, message_id, reply_markup', 'Изменить только клавиатуру']
]
},
{
type: 'code',
language: 'json',
title: 'editMessageText',
code: `POST /bot{token}/editMessageText
{
"chat_id": 1000000000000,
"message_id": 42,
"text": "Статус обновлён ✅",
"reply_markup": {
"inline_keyboard": [
[{ "text": "Обновить снова", "callback_data": "refresh" }]
]
}
}`
},
{
type: 'code',
language: 'json',
title: 'Ответ (стандартный Telegram Message с edit_date)',
code: `{
"ok": true,
"result": {
"message_id": 42,
"from": { "id": 1234567890, "is_bot": true, "first_name": "Мой бот", "username": "my_bot" },
"chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" },
"date": 1710000000,
"edit_date": 1710003600,
"text": "Статус обновлён ✅",
"reply_markup": {
"inline_keyboard": [[{ "text": "Обновить снова", "callback_data": "refresh" }]]
}
}
}`
},
{
type: 'code',
language: 'json',
title: 'Ошибка — сообщение не найдено',
code: `{ "ok": false, "error_code": 400, "description": "Bad Request: message to edit not found" }`
}
]
},
{
id: 'callback-queries',
title: 'Callback Query (inline-кнопки)',
blocks: [
{
type: 'paragraph',
text: 'При нажатии inline-кнопки в мессенджере Lendry ID frontend отправляет событие на backend. Bot Engine формирует стандартный Telegram Update с callback_query и доставляет его боту через webhook или getUpdates. Бот отвечает методом answerCallbackQuery — клиент получает toast или alert.'
},
{
type: 'list',
items: [
'Inbound: POST /bots/by-username/{botRef}/callback — JWT пользователя, тело { messageId, callbackData }',
'Delivery: RabbitMQ → сериализация в callback_query Update → webhook / long polling',
'Outbound: POST /bot{token}/answerCallbackQuery — callback_query_id, text?, show_alert?, url?',
'Realtime: событие bot_callback_answer — снятие loading-состояния кнопки и показ уведомления'
]
},
{
type: 'code',
language: 'json',
title: 'POST /bots/by-username/my_service_bot/callback',
code: `Authorization: Bearer ACCESS_TOKEN
{
"messageId": 42,
"callbackData": "confirm"
}`
},
{
type: 'code',
language: 'json',
title: 'Telegram Update (callback_query) — получает бот',
code: `{
"update_id": 100002,
"callback_query": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"from": { "id": 1000000000000, "is_bot": false, "first_name": "Иван", "username": "ivan" },
"message": {
"message_id": 42,
"from": { "id": 1234567890, "is_bot": true, "first_name": "Мой бот", "username": "my_bot" },
"chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" },
"date": 1710000000,
"text": "Выберите действие:",
"reply_markup": {
"inline_keyboard": [[{ "text": "✅ Подтвердить", "callback_data": "confirm" }]]
}
},
"chat_instance": "bot-uuid:user-uuid",
"data": "confirm"
}
}`
},
{
type: 'code',
language: 'json',
title: 'answerCallbackQuery — ответ бота',
code: `POST /bot{token}/answerCallbackQuery
{
"callback_query_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"text": "Действие выполнено!",
"show_alert": false
}
// Ответ:
{ "ok": true, "result": true }`
},
{
type: 'callout',
variant: 'info',
title: 'Telegraf',
text: 'В Telegraf обработчик bot.action("confirm", ...) и bot.on("callback_query", ...) работают без изменений — достаточно указать apiRoot на Lendry Bot API.'
}
]
},
{
id: 'media',
title: 'Медиа (sendPhoto / sendDocument)',
blocks: [
{
type: 'paragraph',
text: 'Базовая поддержка отправки медиа: параметр photo или document — строка с публичным URL или file_id. Файл сохраняется в mediaUrl сообщения; клиент мессенджера получает событие bot_message с messageType photo или document. Загрузка multipart/form-data напрямую в MinIO — в roadmap.'
},
{
type: 'code',
language: 'json',
title: 'sendPhoto',
code: `POST /bot{token}/sendPhoto
{
"chat_id": "USER_UUID",
"photo": "https://cdn.example.com/image.jpg",
"caption": "Скриншот отчёта",
"reply_markup": {
"inline_keyboard": [[{ "text": "Скачать PDF", "callback_data": "download_pdf" }]]
}
}`
},
{
type: 'code',
language: 'json',
title: 'sendDocument',
code: `POST /bot{token}/sendDocument
{
"chat_id": 1000000000000,
"document": "https://cdn.example.com/report.pdf",
"caption": "Ежемесячный отчёт"
}`
},
{
type: 'code',
language: 'json',
title: 'Фрагмент ответа sendPhoto',
code: `{
"ok": true,
"result": {
"message_id": 43,
"photo": [{ "file_id": "https://cdn.example.com/image.jpg", "width": 320, "height": 240 }],
"caption": "Скриншот отчёта",
"reply_markup": { "inline_keyboard": [[{ "text": "Скачать PDF", "callback_data": "download_pdf" }]] }
}
}`
}
]
},
{
id: 'realtime-events',
title: 'Realtime-события мессенджера',
blocks: [
{
type: 'paragraph',
text: 'Исходящие сообщения и интерактивные обновления бота доставляются пользователю через WebSocket (RabbitMQ → notification service). Frontend мессенджера подписывается на эти события для рендера UI.'
},
{
type: 'table',
headers: ['Событие', 'Когда', 'Ключевые поля payload'],
rows: [
['bot_message', 'sendMessage / sendPhoto / sendDocument', 'messageId, text, messageType, mediaUrl, replyMarkup, telegramReplyMarkup'],
['bot_message_edited', 'editMessageText / editMessageReplyMarkup', 'messageId, text, replyMarkup, editedAt'],
['bot_callback_answer', 'answerCallbackQuery', 'callbackQueryId, text, showAlert, url'],
['bot_menu_button_updated', 'setChatMenuButton / BotFather /setmenubutton', 'composerMenuButtonJson, composerWebAppUrl, buttonText'],
['bot_profile_updated', 'BotFather /setdescription и др.', 'description, aboutText, botPicUrl, menuButtonJson']
]
},
{
type: 'code',
language: 'json',
title: 'Пример payload bot_menu_button_updated',
code: `{
"botId": "uuid",
"botUsername": "my_service_bot",
"composerWebAppUrl": "https://app.example.com/webapp",
"composerMenuButtonJson": "{\\"type\\":\\"web_app\\",\\"text\\":\\"Каталог\\",\\"web_app\\":{\\"url\\":\\"https://app.example.com/webapp\\"}}",
"buttonText": "Каталог"
}`
},
{
type: 'code',
language: 'json',
title: 'Пример payload bot_message',
code: `{
"botId": "uuid",
"botUsername": "my_service_bot",
"chatId": "1000000000000",
"messageId": 42,
"text": "Выберите действие:",
"messageType": "text",
"mediaUrl": null,
"replyMarkup": {
"kind": "inline",
"rows": [[{ "text": "✅ Подтвердить", "callbackData": "confirm" }]]
},
"telegramReplyMarkup": {
"inline_keyboard": [[{ "text": "✅ Подтвердить", "callback_data": "confirm" }]]
}
}`
}
]
},
{
id: 'inbound',
title: 'Входящие сообщения (Webhook / Long Polling)',
blocks: [
{
type: 'paragraph',
text: 'Когда пользователь Lendry ID пишет боту, событие попадает в RabbitMQ (очередь chat.message.bot_inbound). Bot Engine сериализует его в Telegram Update и доставляет на webhook или в очередь getUpdates.'
},
{
type: 'list',
items: [
'POST /bots/by-username/{botRef}/messages — отправить сообщение боту (JWT пользователя)',
'POST /bots/by-username/{botRef}/callback — нажатие inline-кнопки → callback_query Update',
'Webhook: POST на ваш URL с телом Update; заголовок X-Telegram-Bot-Api-Secret-Token при secret_token',
'Long polling: getUpdates без webhook; timeout до 50 секунд',
'Realtime fallback: если RabbitMQ недоступен, доставка выполняется синхронно'
]
},
{
type: 'code',
language: 'json',
title: 'Telegram Update (входящее сообщение)',
code: `{
"update_id": 100001,
"message": {
"message_id": 1,
"from": { "id": 1000000000000, "is_bot": false, "first_name": "Иван", "username": "ivan" },
"chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" },
"date": 1710000000,
"text": "Привет!"
}
}
// Callback query — см. раздел «Callback Query»`
}
]
},
{
id: 'mini-apps',
title: 'Mini Apps (Web Apps)',
blocks: [
{
type: 'paragraph',
text: 'Mini App можно привязать тремя способами: (1) глобальная кнопка меню через menuButton / setChatMenuButton — рекомендуемый способ; (2) legacy webAppUrl через PATCH /bots/{botId}/web-app; (3) inline-кнопка web_app в reply_markup сообщения. Для проверки подлинности пользователя на backend используйте HMAC-SHA256 над initData с секретом HMAC_SHA256("WebAppData", bot_token) — как в Telegram.'
},
{
type: 'code',
language: 'bash',
title: 'POST /bots/web-app/validate',
code: `curl -s -X POST http://localhost:3000/bots/web-app/validate \\
-H 'Content-Type: application/json' \\
-d '{
"initData": "query_id=...&user=%7B%22id%22%3A123%7D&auth_date=1710000000&hash=...",
"botToken": "YOUR_BOT_TOKEN"
}'`
},
{
type: 'code',
language: 'json',
title: 'Ответ при успешной проверке',
code: `{
"valid": true,
"userJson": "{\\"id\\":123,\\"first_name\\":\\"Иван\\"}",
"authDate": "1710000000"
}`
}
]
},
{
id: 'limits',
title: 'Лимиты и безопасность',
blocks: [
{
type: 'table',
headers: ['SystemSetting', 'По умолчанию', 'Назначение'],
rows: [
['BOT_MAX_BOTS_PER_USER', '5', 'Максимум ботов на одного пользователя'],
['BOT_API_RATE_LIMIT_PER_SECOND', '30', 'Запросов Bot API на токен в секунду (Redis)']
]
},
{
type: 'list',
items: [
'Токен бота хранится как SHA-256 hash; кеш валидации — Redis (TTL 5 мин)',
'При revoke или блокировке кеш токена инвалидируется',
'Сообщения бота доставляются через WebSocket: bot_message, bot_message_edited, bot_callback_answer',
'callback_query_id хранится в Redis с TTL для answerCallbackQuery',
'Администраторы с правом bots.manage.all могут просматривать все боты и блокировать злоупотребления'
]
}
]
},
{
id: 'admin',
title: 'Администрирование ботов',
blocks: [
{
type: 'paragraph',
text: 'Endpoints ниже требуют JWT администратора и право bots.manage.all (входит в системную роль admin).'
},
{
type: 'list',
items: [
'GET /admin/bots — список всех ботов платформы (?search, ?page, ?limit)',
'GET /admin/bots/metrics — totalBots, activeBots, blockedBots, totalMessages, totalChats',
'GET /admin/bots/{botId} — карточка любого бота',
'PATCH /admin/bots/{botId}/active — { "isActive": false } для блокировки Bot API'
]
}
]
},
{
id: 'examples',
title: 'Примеры интеграции',
blocks: [{ type: 'bot-examples' }]
}
]
},
{
slug: 'api-reference',
title: 'Справочник API',
description: 'Полный список REST endpoints Lendry ID API. Swagger: /api на api-gateway.',
sections: [
{
id: 'endpoints',
title: 'Endpoints',
blocks: [{ type: 'api-reference' }]
},
{
id: 'swagger',
title: 'OpenAPI / Swagger',
blocks: [
{
type: 'paragraph',
text: 'Интерактивная документация доступна по адресу http://localhost:3000/api (Swagger UI). Все DTO и схемы генерируются автоматически из NestJS decorators.'
}
]
}
]
}
];
export function getDocPage(slug: string): DocPage | undefined {
return docPages.find((page) => page.slug === slug);
}
export function getAllDocSlugs() {
return docPages.map((page) => page.slug);
}