Sofa Integration
Интеграция NetHunt CRM с Voice AI (SaaS API). Автоматически инициирует исходящие звонки, обрабатывает события звонка (start/end), обновляет CRM с AI-аналитикой и повторяет неуспешные звонки по расписанию.
Что делает интеграция
- Инициирует исходящий звонок — через SaaS API Voice Assistant (
webinar-call-originate) - Инициализирует агента — подтягивает данные клиента из NetHunt CRM для персонализации разговора (
webinar-agent-init) - Обрабатывает события звонка —
media_start/media_endот Voice Assistant (webinar-call-events) - Post-call обработка — AI-анализ транскрипции, обновление CRM с квалификацией и резюме (
webinar-agent-post-call) - Автоматические retry — повторяет неуспешные звонки (< 5 сек) по расписанию в фиксированные окна (
retry)
Быстрый старт
# Локальная разработка
pnpm start sofa
# Деплой на dev
pnpm deploy:dev
# Деплой на production
pnpm deploy
# Просмотр логов
wrangler tail --env devАрхитектура
┌─────────────────┐ originate ┌──────────────┐ media_start ┌──────────────┐
│ Trigger/Cron │ ───────────► │ SaaS API │ ─────────────► │ Sofa Worker │
│ (call request) │ │ Voice Asst. │ │ call-events │
└─────────────────┘ └──────┬───────┘ └──────────────┘
│ │
│ media_end │ создаёт запись
▼ │ в sofa_calls
┌──────────────┐ │
│ Sofa Worker │◄──────────────────────┘
│ call-events │
└──────┬───────┘
│
┌───────────────┼───────────────┐
│ duration < 5s │ │ duration >= 5s
▼ │ ▼
┌──────────────┐ │ ┌──────────────┐
│ pending_retry │ │ │ post-call │
│ → cron retry │ │ │ → AI анализ │
└──────────────┘ │ │ → CRM update │
│ └──────────────┘
│
┌──────────────┐
│ NetHunt CRM │
│ (обновление)│
└──────────────┘Структура проекта
src/
├── index.ts # Entry point: createIntegration()
├── types.ts # TypeScript типы и интерфейсы
├── migrations/
│ └── index.ts # Миграции D1 (sofa_calls)
├── utils/
│ ├── retry-windows.util.ts # Расчёт окон повторных звонков
│ └── call-analysis.util.ts # AI-анализ звонков (Claude)
└── handlers/
├── index.ts # Barrel export всех handlers
├── webinar-call-originate.ts # Инициация исходящего звонка
├── webinar-agent-init.ts # Инициализация агента (данные из CRM)
├── webinar-call-events.ts # Обработка media_start / media_end
├── webinar-agent-post-call.ts # Post-call: AI-анализ + CRM update
└── retry.ts # Cron: повтор неуспешных звонковHandlers
1. webinar-call-originate
Endpoint: POST /webhook/webinar-call-originateRetries: 3 | Concurrency: max 3
Инициирует исходящий звонок через SaaS API Voice Assistant.
Запрос:
{
"phone_number": "+380991234567"
}| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
phone_number | string | Да | Номер телефона в формате E.164 |
Ответ:
{ "success": true }Логика:
- Валидирует
phone_number - Получает
SAAS_ASSISTANT_IDиз encrypted credentials (D1) - Вызывает
SaasApiClient.originateCall(assistantId, phone_number)
2. webinar-agent-init
Endpoint: POST /webhook/webinar-agent-initRetries: 0
Инициализация голосового агента. Ищет клиента в NetHunt CRM по номеру телефона и возвращает динамические переменные для разговора.
Запрос:
{
"phone_number": "+380991234567"
}Ответ (клиент найден):
{
"type": "conversation_initiation_client_data",
"dynamic_variables": {
"today_is": "06.02.2026",
"time": "14:30:00",
"weekday": "П'ятниця",
"first_message": "Привіт, Іван! Як справи?",
"client_name": "Іван",
"company": "ACME Corp",
"lead_source": "webinar"
}
}Ответ (клиент не найден / нет phone_number):
{
"type": "conversation_initiation_client_data",
"dynamic_variables": {
"today_is": "06.02.2026",
"time": "14:30:00",
"weekday": "П'ятниця",
"first_message": "Привіт! Як справи?",
"client_name": "Client"
}
}Логика:
- Если нет
phone_number— возвращает дефолтный ответ - Получает NetHunt credentials из D1
- Ищет запись в CRM:
NethuntClient.searchRecords({ query: '"Телефон":<phone>' }) - Извлекает:
Name,company,source - Формирует персонализированное приветствие на украинском
3. webinar-call-events
Endpoint: POST /webhook/webinar-call-eventsRetries: 3
Обрабатывает события media_start и media_end от Voice Assistant. Управляет жизненным циклом звонка в БД.
Запрос:
{
"eventType": "media_start",
"callId": "va_call_123",
"companyId": "company_1",
"assistantId": "agent_1",
"assistantPhone": "+380001234567",
"timestamp": "2026-02-06T14:30:00.000Z",
"clientPhone": "+380991234567",
"duration": 45
}| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
eventType | string | Да | "media_start" или "media_end" |
callId | string | Да | Voice Assistant call ID |
companyId | string | Нет | ID компании |
assistantId | string | Нет | ID агента (для фильтрации) |
assistantPhone | string | Нет | Телефон агента |
timestamp | string | Нет | Время события |
clientPhone | string | Нет | Телефон клиента (только для media_end) |
duration | number | Нет | Длительность в секундах (только media_end) |
Логика media_start:
- Проверяет лимит активных звонков (max 3 со статусом
in_call) - Если лимит достигнут — возвращает ошибку
- Создаёт запись в
sofa_callsсо статусомin_call
Логика media_end:
- Ищет запись звонка по
callId - Если
duration < 5 секунд— звонок считается неуспешным:- Статус →
pending_retry - Инкремент
retry_attempt - Рассчитывает
next_retry_at(следующее окно: 10:40 / 13:40 / 16:40)
- Статус →
- Если
duration >= 5 секунд— звонок успешен:- Статус →
waiting_postcall
- Статус →
4. webinar-agent-post-call
Endpoint: POST /webhook/webinar-agent-post-callRetries: 0
Обработка завершённого звонка. Получает транскрипцию от Voice Assistant, анализирует через Claude AI, обновляет карточку в NetHunt CRM.
Запрос: полный payload от ElevenLabs Post-Call Webhook
{
"conversation_id": "conv_456",
"transcript": [
{ "role": "agent", "message": "Привіт!", "time_in_call_secs": 0 },
{ "role": "user", "message": "Дякую за дзвінок", "time_in_call_secs": 2 }
],
"metadata": {
"call_duration_secs": 120,
"termination_reason": "user_ended"
},
"analysis": {
"call_successful": "success",
"transcript_summary": "Клієнт підтвердив участь"
},
"conversation_initiation_client_data": {
"dynamic_variables": {
"phone_number": "+380991234567"
}
}
}Логика:
- Поиск записи звонка в БД (стратегия fallback):
- По
conversation_id - По
phone_numberсо статусомwaiting_postcall/pending_retry - Самая свежая запись (fallback)
- По
- Определение статуса: если
call_successfulсодержит "refused" или "відмова" →refused, иначе →completed - Обновление записи в БД: статус, телефон, conversation_id, транскрипт, outcome, summary, duration
- Поиск в CRM: по полю "Телефон" в NetHunt
- Формирование комментария:
- Дата/время, длительность, результат, резюме, транскрипция
- Если уже есть комментарий — консолидация через Claude (объединение старых + новых записей)
- Max длина: 2000 символов
- AI-анализ звонка (если транскрипт > 50 символов):
qualification: Холодный / Теплый / ГорячийsalesProbability: 0-100interestLevel: Низкий / Средний / Высокийbarriers: основные барьерыsummary: краткое резюмеoutcome: hung_up / unclear / interested_call_later / refused / completed
Обновляемые поля в CRM:
| Поле CRM | Описание |
|---|---|
Комментарий | Консолидированная история звонков |
AI Квалификация | Холодный / Теплый / Горячий |
AI Вероятность продажи | Десятичное число 0-1 (salesProbability / 100) |
AI Уровень заинтересованности | Низкий / Средний / Высокий |
AI Основные барьеры | Текст с описанием барьеров |
AI summary | Краткое резюме разговора |
AI Попыток связаться | Общее количество звонков на этот номер |
Этап | Условно по outcome (см. ниже) |
Маппинг outcome → Этап:
| Outcome | Этап |
|---|---|
hung_up | "Упала заявка на повторный контакт" |
unclear | "Упала заявка на повторный контакт" |
interested_call_later | "Упала заявка на повторный контакт" |
refused | "Отказано" |
completed | не меняется |
5. retry (cron)
Endpoint: нет (только cron) Retries: 3 Cron: */5 * * * * (каждые 5 минут)
Автоматически повторяет неуспешные звонки.
Логика:
- Запрашивает все звонки с
status = 'pending_retry'иnext_retry_at <= NOW - Для каждого звонка:
- Если
retry_attempt >= 3→ статусfailed, прекращает попытки - Иначе → вызывает
SaasApiClient.originateCall(assistantId, phone) - При успехе: сохраняет новый
va_call_id, статус →pending, обнуляетnext_retry_at - При ошибке: логирует, переходит к следующему
- Если
Ответ:
{ "success": true, "processed": 2 }Retry логика
Окна повторных звонков
Неуспешные звонки (duration < 5 сек) планируются на ближайшее окно:
| Окно | Время |
|---|---|
| 1 | 10:40 |
| 2 | 13:40 |
| 3 | 16:40 |
Если все окна текущего дня прошли — следующий звонок в 10:40 на следующий день.
Полный цикл retry
media_end (duration < 5s)
│
▼
status: pending_retry
retry_attempt: +1
next_retry_at: ближайшее окно
│
▼ (cron каждые 5 мин проверяет)
│
├── retry_attempt < 3 → SaasAPI.originateCall()
│ │
│ ├── success → status: pending (ждёт media_start)
│ └── fail → логирует, пропускает
│
└── retry_attempt >= 3 → status: failed (прекращает)Лимит: max 3 retry попытки
База данных
Таблица sofa_calls
CREATE TABLE sofa_calls (
id TEXT PRIMARY KEY,
call_id TEXT NOT NULL UNIQUE, -- Voice Assistant call ID (оригинальный)
va_call_id TEXT, -- VA call ID при retry (новый)
status TEXT NOT NULL, -- Статус звонка
direction TEXT, -- "outbound"
phone TEXT, -- Телефон клиента
conversation_id TEXT, -- ElevenLabs conversation ID
transcript TEXT, -- Полный транскрипт
outcome TEXT, -- Результат звонка
summary TEXT, -- AI-сгенерированное резюме
duration INTEGER, -- Длительность в секундах
metadata TEXT, -- JSON: { assistantId, assistantPhone }
retry_attempt INTEGER DEFAULT 0, -- Количество retry
next_retry_at TEXT, -- ISO дата следующего retry
created_at TEXT,
updated_at TEXT
);Индексы: status, created_at, phone, conversation_id, next_retry_at, va_call_id
Статусы звонков
| Статус | Описание |
|---|---|
in_call | Звонок активен (получен media_start) |
waiting_postcall | Звонок завершён, ждёт post-call данные |
pending_retry | Неуспешный, запланирован retry |
pending | Retry инициирован, ждёт media_start |
completed | Успешно завершён |
refused | Клиент отказался |
failed | Превышен лимит retry (3 попытки) |
Переменные окружения
Cloudflare Bindings (wrangler.toml)
| Binding | Тип | Описание |
|---|---|---|
INTEG_DB | D1 | База данных (credentials + sofa_calls) |
INTEG_KV | KV | KV хранилище |
Глобальные секреты (Doppler)
| Переменная | Описание |
|---|---|
CRYPTO_KEY | Ключ шифрования credentials (AES-256-GCM) |
CRYPTO_SALT | Соль для PBKDF2 key derivation |
CLAUDE_API_KEY | API ключ Anthropic Claude (для AI-анализа) |
SAAS_API_URL | URL SaaS API (api.dev.happ.tools / api.happ.tools) |
SAAS_ACCESS_TOKEN | Токен доступа к SaaS API |
Per-integration секреты (D1 encrypted)
| Переменная | Описание |
|---|---|
NETHUNT_EMAIL | Email для NetHunt API |
NETHUNT_API_KEY | API ключ NetHunt |
NETHUNT_FOLDER_ID | ID папки в NetHunt CRM |
SAAS_ASSISTANT_ID | ID ассистента для звонков через SaaS API |
Как работает код
Entry Point (index.ts)
import { createIntegration } from "@happ-integ/core";
import * as handlers from "./handlers";
import { SOFA_MIGRATIONS } from "./migrations";
export default createIntegration({
name: "sofa",
handlers: Object.values(handlers),
migrations: SOFA_MIGRATIONS,
secrets: ["NETHUNT_EMAIL", "NETHUNT_API_KEY", "NETHUNT_FOLDER_ID", "SAAS_ASSISTANT_ID"],
scheduled: [
{
cron: "*/5 * * * *",
handlerName: "retry",
payload: { triggered_by: "cron" },
},
],
});Handler (пример: webinar-call-originate)
import { defineHandler } from "@happ-integ/core";
import { SaasApiClient } from "@happ-integ/saas-api";
export const webinarCallOriginateHandler = defineHandler<IWebinarCallOriginatePayload, IWebinarCallOriginateResponse>({
name: "webinar-call-originate",
retries: 3,
endpoint: "POST /webhook/webinar-call-originate",
concurrency: { limit: 3 },
async handler({ payload, env, step, ctx, creds, integrationName }) {
const { phone_number } = payload;
const sofaCreds = await step.run("creds.get", () => creds.get<ISofaCredentials>(integrationName));
const saasClient = new SaasApiClient(env.SAAS_API_URL, env.SAAS_ACCESS_TOKEN);
await step.run("saas.originate", () =>
saasClient.originateCall(sofaCreds.SAAS_ASSISTANT_ID, phone_number)
);
return { success: true };
},
});Тестирование
# Запуск тестов
pnpm test
# Запуск с покрытием
pnpm test --coverageTroubleshooting
Webhook не обрабатывается
- Проверьте что Worker запущен:
curl https://integ.dev.happ.tools/sofa/health - Проверьте логи:
wrangler tail --env dev
Звонки не ретраятся
- Проверьте что cron работает: retry handler вызывается каждый час
- Проверьте записи в БД:
SELECT * FROM sofa_calls WHERE status = 'pending_retry' - Проверьте
next_retry_at— должно быть <= текущего времени - Проверьте
retry_attempt< 3
AI-анализ не работает
- Проверьте
CLAUDE_API_KEYв Doppler - Транскрипт должен быть > 50 символов для запуска анализа
- Проверьте логи на ошибки парсинга JSON от Claude
Локальная разработка не работает
# 1. Проверьте Doppler
doppler login
doppler configure
# 2. Запустите worker
pnpm start sofaСвязанные документы
- INTEGRATION_CHECKLIST.md — чеклист создания интеграции
- SECRETS.md — управление секретами
- DEVELOPMENT.md — локальная разработка
- DATABASE.md — работа с базой данных