Полный путь от доступа к кабинету до "бот отвечает клиентам". Self-service через обязательный onboarding-визард, без env vars и без рестартов apps. Визард vertical-aware: набор шагов зависит от выбранной вертикали (generic vs обменник).
- Развёрнуты
apps/api+apps/worker+apps/admin-ui(см.../../README.md#quick-start) PLATFORM_PUBLIC_URLзадан в env apps/api (напримерhttps://api.acme.com) — нужен чтобы auto-setWebhook работал- У бизнеса есть:
- API key нужного AI-провайдера (BYOK): OpenAI / OpenRouter / Anthropic / Jina / Cohere или локальная Ollama
- Telegram bot token (через
@BotFatherза 30 секунд) — или личный аккаунт для userbot (MTProto), или WhatsApp / Facebook Messenger / VK / MAX / web-виджет - (опц.) document'ы с информацией бизнеса для KB (тарифы, FAQ, политика)
Для пост-демо exchange alpha есть отдельный alpha-trial runbook:
../gtm/exchange/trial-flow.md. Он фиксирует
ручной 14-day alpha через superadmin, invite-only вход, BYOK-инструкцию и
60-минутный dry run до отвечающего бота.
Публичный self-service signup закрыт: POST /api/auth/signup → 403 signup_disabled, страницы /signup в admin-UI больше нет. Открывается
только явно — env ALLOW_PUBLIC_SIGNUP=1 на apps/api (прокидывается в
allowSignup, см. apps/api/src/index.ts + apps/api/src/routes/auth.ts).
Способы завести tenant'а:
- Invite (основной путь) — superadmin шлёт magic-link
(
POST /api/admin/admins/invite { email, role }), приглашённый активирует через/accept-invite(POST /api/auth/accept-invite { token, password }). - Открыть signup — выставить
ALLOW_PUBLIC_SIGNUP=1(нужно и для локальной разработки, см.../../AGENTS.md). ТогдаPOST /api/auth/signup { email, password, tenantSlug? }создаёт tenant- первого admin'а с
role=superadmin.
- первого admin'а с
Token живёт 30 дней (signAuthToken в apps/api/src/lib/auth.ts),
хранится в localStorage[lead_engine_token].
После логина OnboardingGate (apps/admin-ui/src/App.tsx) читает
GET /api/admin/onboarding-status и редиректит:
done=false→/onboarding(кабинет недоступен, пока setup не завершён)done=true→/dashboard
Fail-open: если status-эндпоинт недоступен, считаем done=true (не лочим
пользователя).
Визард — динамическая step-машина (apps/admin-ui/src/pages/SaasOnboarding.tsx),
набор шагов зависит от вертикали:
| Шаг | Generic | Обменник | Обязателен |
|---|---|---|---|
| Вертикаль (бизнес) | ✓ | ✓ | да |
| Канал | ✓ | ✓ | да (≥1 messenger) |
| LLM | ✓ | ✓ | да (чат-LLM) |
| Курсы | — | ✓ | да (обменник) |
| Реквизиты | — | ✓ | да (обменник) |
| База знаний | ✓ | ✓ | нет |
| Бизнес-данные | — | ✓ | нет |
| Готово | ✓ | ✓ | — |
Обменник определяется по funnels.vertical_template_id='exchange_v1'
или funnels.slug='exchange' (флаг isExchange в onboarding-status).
GET /api/admin/onboarding-status (apps/api/src/routes/admin-onboarding.ts)
возвращает: channelConnected, chatLlmConfigured, hasKbDocuments,
vertical, isExchange, funnelInstalled, activeRateCount,
requisiteCount, done.
exchangeReady = funnelInstalled && activeRateCount >= 1 && requisiteCount >= 1
done = channelConnected && chatLlmConfigured && (!isExchange || exchangeReady)
- Generic: достаточно канал + чат-LLM (KB больше не входит в
done). - Обменник: дополнительно нужны установленная воронка + ≥1 активный курс + ≥1 реквизит (кошелёк или платёжный метод).
Сайдбар тоже vertical-aware (apps/admin-ui/src/components/app-shell.tsx):
обменным тенантам показывается «Обменник» и скрываются «Каталог / Навыки /
Хуки / Стили / Эксперименты / Партнёры»; остальным — наоборот.
http://localhost:5173/channels
В onboarding-визарде базовый путь ведёт через Telegram bot/userbot, а полный
экран /channels имеет семь вкладок: Telegram, Личный аккаунт,
WhatsApp, Messenger, VK, MAX, Web. Канал можно подключить в визарде
или перейти по ссылке "Все типы каналов" на /channels.
- Открыть Telegram, найти
@BotFather, отправить/newbot - BotFather даст token формата
123456789:AAEhBP... - Вставить token в форму "Bot token", нажать "Подключить"
Backend (POST /api/admin/channels/telegram):
1. Validate format regex /^\d+:[\w-]{30,}$/ → 400 если bad
2. Quota check (canAddChannel) → 402 если over plan limit
3. TelegramClient.getMe() с token → 401 если bad
4. encrypt token AES-256-GCM → tenant_secrets[channel_telegram_bot_<username>]
5. INSERT channels (kind=telegram_bot, external_id=<username>, ...)
6. setWebhook(url=<PLATFORM_PUBLIC_URL>/webhook/telegram/<tenantSlug>,
secret_token=<TELEGRAM_WEBHOOK_SECRET>)
7. recordAudit('channel.create', ...)
8. reloader.reloadChannels(tenantId) ← hot-reload в apps/api
9. apps/worker подхватит ≤30 сек через polling
UI отображает:
✓ Бот @acme_support_bot подключён и активирован — webhook настроен,
канал работает.
curl-вариант для CI/CD:
curl -X POST http://localhost:3000/api/admin/channels/telegram \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"botToken":"123456789:AAE..."}'- Открыть Meta for Developers → ваше WhatsApp Business App → API Setup
- Скопировать
Phone number ID(15-значный numeric) и сгенерировать permanent system user access token (не временный 24h) - Вставить оба значения в форму на вкладке "WhatsApp", optional
Business Account ID
Backend (POST /api/admin/channels/whatsapp):
1. Validate phoneNumberId regex /^\d{10,20}$/ → 400 если bad
2. Quota check → 402 если over limit
3. WhatsAppClient.getPhoneInfo() — GET /<phone_number_id>:
- 401 если bad token, 404 если bad phoneNumberId
- returns { verifiedName, displayPhoneNumber, qualityRating }
4. encrypt token → tenant_secrets[channel_whatsapp_<phoneNumberId>]
5. INSERT channels (kind=whatsapp, external_id=phoneNumberId,
metadata: { verifiedName, displayPhoneNumber, qualityRating })
6. recordAudit + reloadChannels
В отличие от Telegram, Meta webhook нельзя настроить автоматически —
Meta не предоставляет API для self-serve webhook subscription. Response
содержит webhookSetupHint:
{
"webhookSetupHint": {
"url": "https://api.example.com/webhook/whatsapp/acme",
"verifyToken": "<WHATSAPP_VERIFY_TOKEN>",
"appSecretHint": "Meta dashboard → App settings → Basic → App Secret — добавить в WHATSAPP_APP_SECRET env"
}
}UI показывает эти три значения в banner для copy-paste в Meta dashboard → Webhooks → Edit subscription:
- Callback URL =
webhookSetupHint.url - Verify Token =
webhookSetupHint.verifyToken - Подписаться на
messagesevents
После этого Meta отправит GET с challenge, Lead Engine ответит plaintext
challenge (см. verifyWebhookSubscription в packages/channel-whatsapp).
curl:
curl -X POST http://localhost:3000/api/admin/channels/whatsapp \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"phoneNumberId":"123456789012345","accessToken":"EAAJZB..."}'Канал Messenger ездит на том же Meta Graph API, что и WhatsApp, поэтому
сетап почти идентичен (packages/channel-facebook зеркалит
packages/channel-whatsapp). Отличия: события вебхука приходят как
entry[].messaging[] (а не entry[].changes[]), endpoint отправки —
/me/messages, а идентификатор канала — это Facebook Page ID.
- Открыть Meta for Developers → ваше приложение → добавить продукт Messenger → раздел Access Tokens
- Привязать Facebook Page и сгенерировать Page Access Token
(нужен permission
pages_messaging; для production — App Review) - Вставить Page Access Token в форму на вкладке "Messenger".
Опционально: per-tenant
Verify TokenиApp Secret(иначе фолбэк на envFACEBOOK_VERIFY_TOKEN/FACEBOOK_APP_SECRET)
Backend (POST /api/admin/channels/facebook):
1. pageAccessToken required → 400 если пусто
2. MessengerClient.getPageInfo() — GET /me:
- 401/403 если bad token
- returns { id (=pageId), name }
3. validate pageId regex /^\d{5,25}$/ → 400 если bad
4. Quota check → 402 если over limit
5. encrypt token → tenant_secrets[channel_facebook_<pageId>]
(+ опц. verifyToken / appSecret в tenant_secrets)
6. upsert channels (kind=facebook, external_id=pageId,
metadata: { pageName })
7. recordAudit + reloadChannels
Как и WhatsApp, Meta webhook нельзя настроить автоматически. Response
содержит webhookSetupHint:
{
"webhookSetupHint": {
"url": "https://api.example.com/webhook/facebook/acme",
"verifyToken": "<FACEBOOK_VERIFY_TOKEN>",
"appSecretHint": "Meta dashboard → App settings → Basic → App Secret — добавить в FACEBOOK_APP_SECRET env (или в форме)"
}
}UI показывает эти значения для copy-paste в Meta dashboard → Messenger → Webhooks → Edit subscription:
- Callback URL =
webhookSetupHint.url - Verify Token =
webhookSetupHint.verifyToken - Подписаться на
messages,messaging_postbacksevents - Subscribe приложение к нужной Page
Meta отправит GET с challenge, Lead Engine ответит plaintext challenge
(см. verifyWebhookSubscription в packages/channel-facebook). Подпись
вебхука (X-Hub-Signature-256, App Secret) проверяется на route-слое
до tenant lookup (apps/api/src/lib/facebook-signature.ts).
24-часовое окно.
send()используетmessaging_type: "RESPONSE". Вне 24 часов с последнего сообщения пользователя Meta разрешает только сообщения с message tag. Capabilities:text,photo,video,voice,document,callbackQuery(postbacks / quick replies),typing.edit/deleteSend API не поддерживает.
curl:
curl -X POST http://localhost:3000/api/admin/channels/facebook \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"pageAccessToken":"EAAJZB..."}'VK-канал работает как бот сообщества: входящие приходят через Callback API,
исходящие отправляются через messages.send. MVP поддерживает private
community messages и text-only ответы; личные аккаунты VK, рассылки, клавиатуры
и media-upload не входят в первый срез.
- Открыть VK community → Управление → Работа с API
- Создать/скопировать ключ доступа сообщества с правами на сообщения
- В Callback API скопировать строку, которую должен вернуть сервер
(
confirmationCode) и, опционально, задать секретный ключ - Вставить
Group ID, access token, confirmation code и secret key во вкладку VK на/channels
Backend (POST /api/admin/channels/vk):
1. groupId/accessToken/confirmationCode required → 400 если пусто
2. VkClient.getGroupInfo() — validate group/token через VK API:
- 401 если bad token / insufficient permissions
- 404 если groupId не найден
3. Quota check → 402 если over limit
4. encrypt token → tenant_secrets[channel_vk_<groupId>]
(+ confirmationCode / secretKey в tenant_secrets)
5. upsert channels(kind=vk, external_id=groupId,
metadata: { groupName, screenName })
6. recordAudit + reloadChannels
Response содержит webhookSetupHint:
{
"webhookSetupHint": {
"url": "https://api.example.com/webhook/vk/acme",
"confirmationCode": "<VK_CONFIRMATION_CODE>",
"secretKeyHint": "Secret key сохранён — payload.secret будет проверяться",
"eventTypes": ["message_new"]
}
}В VK Callback API settings:
- Адрес =
webhookSetupHint.url - Сервер должен вернуть
webhookSetupHint.confirmationCodeна confirmation - Включить event type
message_new - Если задан secret key, VK будет присылать его в
payload.secret; Lead Engine проверит его до pipeline
curl:
curl -X POST http://localhost:3000/api/admin/channels/vk \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"groupId":"123456789","accessToken":"vk1.a...","confirmationCode":"abcd","secretKey":"optional"}'MAX-канал работает как чат-бот организации/ИП: входящие приходят через Bot API
Webhook, исходящие отправляются через POST /messages. MVP поддерживает
text-only message_created; media, rich keyboards и callback buttons — follow-up.
Требования MAX API:
- бот создаётся в business.max.ru у верифицированного профиля организации/ИП;
- token передаётся только через header
Authorization: <token>; - production webhook должен быть HTTPS на порту 443;
- secret из subscription приходит в
X-Max-Bot-Api-Secret.
- В business.max.ru открыть Чат-боты → Интеграция
- Скопировать Bot token
- Во вкладке MAX на
/channelsвставить token; webhook secret можно указать вручную или оставить пустым — backend сгенерирует его - В MAX создать Webhook subscription на URL из
webhookSetupHint
Backend (POST /api/admin/channels/max):
1. botToken required → 400 если пусто
2. MaxClient.getBotInfo() — validate bot token через GET /me:
- 401 если bad token / insufficient permissions
3. Quota check → 402 если over limit
4. encrypt token → tenant_secrets[channel_max_<botId>]
5. encrypt webhook secret → tenant_secrets[channel_max_<botId>_webhook_secret]
6. upsert channels(kind=max, external_id=botId,
metadata: { username, botName })
7. recordAudit + reloadChannels
Response содержит webhookSetupHint:
{
"webhookSetupHint": {
"url": "https://api.example.com/webhook/max/acme/778899",
"secret": "max_...",
"updateTypes": ["message_created"],
"requirement": "Production MAX webhooks require HTTPS on port 443."
}
}MAX Webhook subscription:
- URL =
webhookSetupHint.url(/webhook/max/<tenantSlug>/<botId>—botIdнужен для tenants с несколькими MAX-ботами) - Secret =
webhookSetupHint.secret - Update types =
message_created - Lead Engine проверяет
X-Max-Bot-Api-Secretдо pipeline
curl:
curl -X POST http://localhost:3000/api/admin/channels/max \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"botToken":"max-bot-token","webhookSecret":"optional"}'Помимо бота, тенант может подключить личный аккаунт (userbot) для
работы из обычного Telegram-аккаунта. Подсистема userbot always-on;
MTProto-креды (api_id / api_hash с my.telegram.org) хранятся
per-tenant в tenant_secrets с fallback на env TELEGRAM_API_ID /
TELEGRAM_API_HASH (apps/api/src/lib/userbot-creds.ts).
Flow (вкладка "Личный аккаунт"):
POST /api/admin/channels/userbot/start { phone, apiId?, apiHash? }
├─ apiId/apiHash переданы → валидируются и сохраняются в tenant_secrets
├─ не переданы → резолв из tenant_secrets, затем env
├─ кредов нет нигде → 400 "укажите API ID и API Hash" (а не глобальный 503)
└─ ок → MTProto sendCode → { loginId, awaiting: "code" }
POST /api/admin/channels/userbot/verify { loginId, code } → signIn / needs2fa
POST /api/admin/channels/userbot/2fa { loginId, password } → SRP → канал создан
Channel credentials хранятся per-tenant в tenant_secrets с fallback на
env, где это нужно:
- WhatsApp
verify_token/app_secret: fallbackWHATSAPP_VERIFY_TOKEN/WHATSAPP_APP_SECRET, передаются вPOST /api/admin/channels/whatsapp { verifyToken?, appSecret? }. - Facebook Messenger
verify_token/app_secret: fallbackFACEBOOK_VERIFY_TOKEN/FACEBOOK_APP_SECRET, передаются вPOST /api/admin/channels/facebook { verifyToken?, appSecret? }. - VK
accessToken,confirmationCode,secretKey: хранятся per-tenant; глобальный env-фолбэк не используется. - MAX
botToken,webhookSecret: хранятся per-channel вtenant_secrets; fallback для локального/legacy bootstrap —MAX_BOT_TOKEN_<SLUG>/MAX_BOT_TOKEN, webhook fallback —MAX_WEBHOOK_SECRET. - Web channel настраивается через
/api/admin/channels/web; опциональный shared-secret для WS —WEB_WS_AUTH_SECRET.
То есть один деплой обслуживает разных тенантов с их собственными Meta/VK/MAX/Telegram-приложениями без общих секретов между бизнесами.
http://localhost:5173/settings
LLM-шаг визарда — компактный accordion по всем purpose'ам: chat
(раскрыт + обязателен) и сворачиваемые опциональные embed / vision /
judge / reranker / transcribe. Списки провайдеров и плейсхолдер модели
адаптируются под purpose (например, reranker → jina/cohere; OpenRouter →
подсказка google/gemini-2.5-flash). Каждый purpose сохраняется через
PUT /api/admin/llm-configs/:purpose.
- Provider:
openai/openrouter/anthropic/ollama - Model: например
gpt-4o-mini - API key: вставляется один раз, encrypted AES-256-GCM
- Base URL: опционально (для прокси / Ollama локального)
- Timeout: опционально
- Provider: обычно
openai - Model:
text-embedding-3-small - API key (нужно paste отдельно — даже если совпадает с chat-ключом)
- Embed dim: дефолт 1536 (фиксированный размер колонки
kb_chunks). Любая современная модель авто-подгоняется под целевую размерность черезfitDim()вllm-router(truncate + L2-renormalize — валидная Matryoshka- редукция для OpenAI v3 / Gemini embedding и т.д.), так что выбирать dim вручную больше не нужно.
Backend (PUT /api/admin/llm-configs/chat):
1. Validate provider ∈ {openai, openrouter, ollama, anthropic, jina, cohere}
2. Validate non-ollama → apiKey required (или secret_ref уже есть)
3. embed purpose → embedDim required
4. encrypt apiKey → tenant_secrets[llm_chat_apikey]
5. UPSERT llm_provider_configs (tenantId, purpose, ...)
6. recordAudit('llm_config.create' | 'update', ...)
7. reloader.reloadLlm(tenantId):
- InMemoryLlmRouter.invalidate(tenantId)
- setConfig для каждого purpose
- mutate LoadedRef.current
curl:
curl -X PUT http://localhost:3000/api/admin/llm-configs/chat \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"provider":"openai","model":"gpt-4o-mini","apiKey":"sk-..."}'
curl -X PUT http://localhost:3000/api/admin/llm-configs/embed \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"provider":"openai","model":"text-embedding-3-small","apiKey":"sk-...","embedDim":1536}'http://localhost:5173/funnel
Нажать "Загрузить шаблон" → выбрать нужный:
| Шаблон | Описание |
|---|---|
recruitment |
Найм (UAE/виза) — расширенная воронка с docs/visa-стадиями |
recruitment_generic |
Найм — простая воронка: лид → квалификация → оффер |
real_estate |
Недвижимость — просмотры → оффер → NOC/ипотека → передача |
saas |
SaaS — discovery → demo → proposal → подписка |
video |
Видеопродакшн — бриф → смета → съёмка → монтаж → сдача |
exchange |
Обменник — крипта/RUB → THB наличные (11 стадий) |
Это ключи
POST /api/admin/funnel/seed(SEED_TEMPLATESвapps/api/src/routes/admin-funnel.ts). Дополнительно есть ключиvisa,modeling,leadengine_sales_v1(мета-демо продажи самого Lead Engine) иskeleton(пустой универсальный костяк для новой вертикали). В визарде шаг «Вертикаль» устанавливает соответствующий vertical template (exchange_v1,real_estate_v1,recruitment_v1,saas_v1,video_v1) — он сеет воронку и проставляетfunnels.vertical_template_id.
curl -X POST http://localhost:3000/api/admin/funnel/seed \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"template":"recruitment_generic"}'Нажать "Настроить с AI" → откроется чат-панель. Описать бизнес в свободной форме — AI задаёт уточняющие вопросы и генерирует воронку под конкретный бизнес. Когда AI готов — показывает preview стадий и полей. Нажать "Применить воронку" → стадии устанавливаются мгновенно.
Добавить стадии и поля через drag-drop редактор на /funnel. Типы стадий
(STAGE_TYPES): form_fill, document_upload, document_signature,
rate_confirmation, external_approval, payment, awaiting_operator,
interaction, assessment, waiting, milestone. Типы полей
(FIELD_TYPES): text, textarea, number, date, select,
multiselect, boolean, phone, email, photo, file, video.
Поверх произвольных стадий лежит универсальная ось фаз — общий язык для
всех вертикалей (packages/verticals/src/phases.ts, миграция
0031_stage_phase.sql):
capture → qualify → offer → [clear] → [fulfill] → won / lost
- В БД у активных стадий хранится
stage_definitions.phase∈qualify | offer | clear | fulfill; якоряcapture/won/lostвыводятся изkind(intake / terminal_won / terminal_lost). qualifyиofferобязательны в каждой воронке;clear(KYC/комплаенс/ документы) иfulfill(доставка/оплата) опциональны.- AI-builder и
POST /api/admin/workflows/applyвалидируют костяк (validateBackbone): монотонность фаз, наличие intake/terminal, обязательныеqualify/offer— иначе400со списком нарушений. GET /api/admin/funnel/phase-statsдаёт vertical-agnostic метрику — число лидов в каждой фазе (сравнимо между вертикалями).
Подробнее — ARCHITECTURE.md#funnel-phase-backbone и
VERTICALS.md.
Полное описание системы обмена (курсы, guardrails, ops-watch, реквизиты, orders) — EXCHANGE.md.
Для обменных тенантов визард добавляет обязательные шаги Курсы и Реквизиты (и опциональные Бизнес-данные).
- Базовые курсы (
exchange_rates): asset+network, маржа %, фикс-комиссия (THB), мин/макс, авто-обновление с рыночного фида. - Approved rate tiers (
exchange_rate_tiers): объёмные ступени сdisplay_rate(показывается клиенту) иmarket_rate(референс). - Эндпоинты:
GET/POST /api/admin/exchange/rates,POST /api/admin/exchange/rates/refresh,POST /api/admin/exchange/rate-card/preview|approve. - Курсы проходят guardrails (
apps/api/src/lib/exchange/guardrails.ts): отклонение эффективного курса от базового >maxDeviationPct(дефолт 35%) → отказ от котировки (ловит опечатки тарифа и мусор из фида).
Шифрованные tenant_secrets (allowlist в
apps/api/src/lib/exchange/requisite-keys.ts):
- кошельки по asset/network (
exchange_wallet_*); - фиксированные платёжные ключи (фиат payment URL, Binance ID, реквизиты карты);
- бизнес-данные (контакт оператора, методы выплат, KYC-политика, часы, адрес).
Эндпоинты GET/POST /api/admin/exchange/requisites. Для завершения
onboarding обменника нужен ≥1 активный курс и ≥1 реквизит.
http://localhost:5173/dashboard
Два пути:
.txt / .md / .json файлы (PDF — TBD). Multipart POST.
curl -X POST http://localhost:3000/api/admin/kb/documents \
-H "Authorization: Bearer $TOKEN" \
-F 'file=@faq.md' \
-F 'title=Часто задаваемые вопросы' \
-F 'topic=faq'JSON body { title, body, topic? }:
curl -X POST http://localhost:3000/api/admin/kb/documents \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"title": "Условия возврата",
"topic": "policy",
"body": "Возврат в течение 14 дней. Полная сумма если товар не использовался..."
}'Backend:
1. ingestText(body) → split на chunks (chunked по tokens) →
embed каждый chunk через resolveEmbedder(tenantId) → INSERT kb_chunks
2. content_hash dedup → если same body → return existing, created=false
3. created=true когда новый row
Прогресс отслеживает GET /api/admin/onboarding-status (см. Шаг 1c).
✓ Вертикаль выбрана
✓ Канал активен @acme_support_bot
✓ Чат-LLM настроен openai / gpt-4o-mini
(обменник) ✓ Воронка + ≥1 курс + ≥1 реквизит
done=true (generic) когда канал активен и чат-LLM настроен; для
обменника — плюс воронка + ≥1 активный курс + ≥1 реквизит. KB в done
не входит. Пока done=false, OnboardingGate держит пользователя
на /onboarding; после — пускает в /dashboard.
http://localhost:5173/diagnostics
Нажать "Запустить проверку" — backend прогоняет:
- channel.telegram — если подключён Telegram bot,
getMeвалиден и token расшифровывается → OK; если активен другой канал (userbot / WhatsApp / Facebook / VK / MAX / web), диагностика показывает fallback "активен канал" - llm.chat — config есть, apiKey decryptable → OK
- llm.embed — config + dim → OK (warn если нет)
- tenant_secrets — sanity check
Per check status + latency + сообщение об ошибке.
В Telegram открыть @acme_support_bot → отправить "Какие условия
возврата?".
Bot отвечает (через ~3-5 сек: LLM + RAG retrieval + LLM generate + worker outbound).
Диалог появляется в списке. Click → видны user message + assistant reply.
В thread'е textarea + "Отправить" — после send:
messagesINSERT сrole='human',meta_json.adminId=<you>conversations.mode = 'human'— AI замолкает на этом conversation- Outbound queue → worker → клиент получает в Telegram message от оператора
Кроме reply есть отдельный toggle "Перехватить" / "Вернуть AI" в header
thread'а — переключает conversations.mode без отправки сообщения:
curl -X PUT http://localhost:3000/api/admin/conversations/<id>/mode \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"mode":"human"}' # AI замолкает
# или
-d '{"mode":"ai"}' # AI снова отвечаетUse-case: оператор взял диалог, разрулил вручную, нажал "Вернуть AI" — бот продолжает обработку с того места.
/dashboard → "Поставить на паузу" → tenant.status = 'suspended' →
ChannelRegistry.reloadTenant evict'ит → webhook возвращает 404 →
inbound сообщения отбрасываются (Telegram retry'ит позже).
"Возобновить" → status='active' → каналы восстанавливаются.
/audit показывает последние 50 действий. Cursor pagination "Загрузить
ещё". Action labels: "Подключён канал", "Изменён LLM конфиг", "Ответ
оператора", "Бот на паузе", etc. Raw secrets никогда не отображаются.
Просто paste новый token в /channels для того же бота → backend
re-encrypts + updates row + reload. Старый ciphertext остаётся в
tenant_secrets (manual cleanup отдельной операцией для безопасности).
Дашборд показывает PlanWidget — текущий план, usage bars (каналы /
KB docs / rate), кнопки "Starter $99" / "Pro $199" если на free.
Flow:
- Клик "Starter $99" →
POST /api/admin/billing/checkout { plan: 'starter' } - Backend:
createCustomer(если нет) +createCheckoutSession(14-day trial) → возвращаетurl - UI:
window.location.href = url— Stripe Checkout - Tenant вводит карту, подтверждает trial
- Stripe POST
/webhook/stripeсcustomer.subscription.created:- upsert
stripe_subscriptions - priceMap[priceId] → newPlan='starter'
tenants.plan = 'starter'(status='trialing')
- upsert
- Redirect назад на
STRIPE_CHECKOUT_SUCCESS_URL - PlanWidget reload → видит новый план, quota = 3 channel / 500 docs
- Следующий
POST /api/admin/channels/...→ quota check проходит
Управление подпиской — кнопка "Управлять" в PlanWidget:
POST /api/admin/billing/portal→ returns Stripe Customer Portal URL- Tenant меняет карту / cancel / change plan через Stripe UI
- Webhook events sync обратно
tenants.plan
curl:
# Start checkout
curl -X POST http://localhost:3000/api/admin/billing/checkout \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"plan":"starter"}'
# Returns: { ok: true, url: "https://checkout.stripe.com/...", sessionId }
# Customer Portal
curl -X POST http://localhost:3000/api/admin/billing/portal \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{}'
# Returns: { ok: true, url: "https://billing.stripe.com/..." }Quota errors (402 Payment Required):
{
"error": "quota_exceeded",
"reason": "max_channels",
"limit": 1, "current": 1,
"plan": "free", "planLabel": "Free",
"upgradeHint": "Перейдите на план Starter ($99/мес) для большего числа каналов"
}UI ловит этот response и показывает Upgrade CTA.
То же самое в /settings — paste новый key, save. Hot-reload.
На странице лида (/leads/:id) — блок "Отправить QR / фото клиенту".
Вставить Telegram file_id или публичный HTTPS URL изображения, нажать "Отправить".
Бот мгновенно пересылает картинку клиенту в Telegram (без перехвата чата).
Основной кейс: оператор обменника сгенерировал cardless-withdrawal QR в банковском приложении → сохранил как фото → отправляет клиенту через admin-UI.
curl -X POST http://localhost:3000/api/admin/leads/42/send-photo \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"photoRef":"https://example.com/qr.png","caption":"Ваш QR для ATM"}'- ❌ Менять env vars apps/api / apps/worker. После initial deploy с
PLATFORM_MASTER_KEY/PLATFORM_PUBLIC_URLничего из env tenant не трогает. - ❌ Рестартовать apps. Все изменения hot-reload.
- ❌ Заходить в БД. Все CRUD-операции через UI / admin API.
- ❌ Вручную дёргать
setWebhookTelegram'у. Авто-настройка при channel-create. - ❌ Делать backup secrets отдельно.
pg_dumpбазы достаточно (мастер- ключ в env apps/api — храните его отдельно в secret-manager).
Полный onboarding скриптом. Требует ALLOW_PUBLIC_SIGNUP=1 на apps/api
(иначе POST /api/auth/signup → 403 signup_disabled) — либо замените
шаг 1 на invite-flow (/accept-invite).
#!/usr/bin/env bash
set -euo pipefail
API="${API:-http://localhost:3000}"
# 1. Signup (нужен ALLOW_PUBLIC_SIGNUP=1)
SIGNUP=$(curl -fsS -X POST "$API/api/auth/signup" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$1\",\"password\":\"$2\",\"tenantSlug\":\"$3\"}")
TOKEN=$(echo "$SIGNUP" | jq -r .token)
# 2. Connect channel
curl -fsS -X POST "$API/api/admin/channels/telegram" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"botToken\":\"$4\"}"
# 3. Configure LLM
curl -fsS -X PUT "$API/api/admin/llm-configs/chat" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"provider\":\"openai\",\"model\":\"gpt-4o-mini\",\"apiKey\":\"$5\"}"
curl -fsS -X PUT "$API/api/admin/llm-configs/embed" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"provider\":\"openai\",\"model\":\"text-embedding-3-small\",\"apiKey\":\"$5\",\"embedDim\":1536}"
# 4. Upload KB
curl -fsS -X POST "$API/api/admin/kb/documents" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"title\":\"FAQ\",\"body\":\"$6\"}"
# 5. Diagnostics
curl -fsS "$API/api/admin/diagnostics" -H "Authorization: Bearer $TOKEN" | jq .Usage:
./onboard.sh founder@acme.com pwd-12345 acme \
'12345:AAE...' 'sk-...' 'Возврат в течение 14 дней.'| Симптом | Причина | Fix |
|---|---|---|
Signup → 403 signup_disabled |
Публичная регистрация закрыта (дефолт) | ALLOW_PUBLIC_SIGNUP=1 на apps/api, или заводите tenant через invite |
| Signup → 409 conflict | Email или slug уже занят | Сменить email/slug |
После логина всегда редирект на /onboarding |
onboarding-status.done=false |
Доведите обязательные шаги визарда (канал + чат-LLM; обменник: + курс + реквизит) |
userbot/start → 400 «укажите API ID и API Hash» |
Нет MTProto-кредов | Передайте apiId/apiHash в запросе или задайте env TELEGRAM_API_ID / TELEGRAM_API_HASH |
| Channel POST → 401 | Telegram отверг token | Проверить что скопировали без пробелов |
| Channel POST → 502 | Telegram unreachable | network issue, retry |
| Webhook не приходит | PLATFORM_PUBLIC_URL неверный или setWebhook не отработал |
Проверить webhookSet: true в response; вручную дёрнуть setWebhook через getWebhookInfo |
| LLM PUT → 500 "requires apiKey" | non-Ollama provider без key | Paste key |
| Bot не отвечает | tenant.status='suspended' или LLM key неверный |
/diagnostics → видно где fail |
| Inbox пустой после сообщения | Webhook не доходит / rate-limit / canal paused | Логи apps/api: webhookRequests{status="429"} — over rate limit; 404 — нет канала |
| Send-from-admin → 409 "no channel" | Channel удалён после inbound | Re-paste token в /channels |
| WhatsApp POST → 401 "Meta rejected" | Bad access token (истёк / нет permissions) | Сгенерировать permanent system user token в Meta dashboard |
| WhatsApp POST → 404 "phoneNumberId not found" | Опечатка в phoneNumberId или token не от этой WABA | Скопировать Phone number ID точно из API Setup |
| WhatsApp webhook не приходит | Meta dashboard webhook не настроен | Скопировать webhookSetupHint.url + verifyToken в Meta dashboard → Webhooks |
| VK POST → 401 "VK rejected" | Bad community token или нет прав на сообщения | Создать новый community access token в VK → Работа с API |
| VK webhook не проходит confirmation | Неверный Group ID или confirmation code не сохранён | Скопировать webhookSetupHint.url + confirmationCode в VK Callback API |
| VK callback → 401 "invalid secret" | Secret key в VK settings не совпадает с сохранённым | Обновить VK secret key на вкладке /channels или в VK settings |
| POST /channels → 402 "quota_exceeded" | План free лимит 1 канал | Upgrade на Starter / Pro через PlanWidget или DELETE старого канала |
| POST /kb/documents → 402 | Free план — 50 docs cap | Удалить старые docs или upgrade plan |
| Stripe Checkout → 503 "stripe_not_configured" | STRIPE_SECRET_KEY пустой на платформе |
Set env STRIPE_SECRET_KEY + STRIPE_PRICE_STARTER + STRIPE_PRICE_PRO |
| После checkout план не обновился | Webhook не получен (нет STRIPE_WEBHOOK_SECRET или Stripe URL не указывает на /webhook/stripe) | Проверить Stripe dashboard → Webhooks → delivery attempts |
См. также ARCHITECTURE.md для глубже под капот.