Skip to content

Sofa Integration

Интеграция NetHunt CRM с Voice AI (SaaS API). Автоматически инициирует исходящие звонки, обрабатывает события звонка (start/end), обновляет CRM с AI-аналитикой и повторяет неуспешные звонки по расписанию.

Что делает интеграция

  1. Инициирует исходящий звонок — через SaaS API Voice Assistant (webinar-call-originate)
  2. Инициализирует агента — подтягивает данные клиента из NetHunt CRM для персонализации разговора (webinar-agent-init)
  3. Обрабатывает события звонкаmedia_start / media_end от Voice Assistant (webinar-call-events)
  4. Post-call обработка — AI-анализ транскрипции, обновление CRM с квалификацией и резюме (webinar-agent-post-call)
  5. Автоматические retry — повторяет неуспешные звонки (< 5 сек) по расписанию в фиксированные окна (retry)

Быстрый старт

bash
# Локальная разработка
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.

Запрос:

json
{
  "phone_number": "+380991234567"
}
ПараметрТипОбязательныйОписание
phone_numberstringДаНомер телефона в формате E.164

Ответ:

json
{ "success": true }

Логика:

  1. Валидирует phone_number
  2. Получает SAAS_ASSISTANT_ID из encrypted credentials (D1)
  3. Вызывает SaasApiClient.originateCall(assistantId, phone_number)

2. webinar-agent-init

Endpoint: POST /webhook/webinar-agent-initRetries: 0

Инициализация голосового агента. Ищет клиента в NetHunt CRM по номеру телефона и возвращает динамические переменные для разговора.

Запрос:

json
{
  "phone_number": "+380991234567"
}

Ответ (клиент найден):

json
{
  "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):

json
{
  "type": "conversation_initiation_client_data",
  "dynamic_variables": {
    "today_is": "06.02.2026",
    "time": "14:30:00",
    "weekday": "П'ятниця",
    "first_message": "Привіт! Як справи?",
    "client_name": "Client"
  }
}

Логика:

  1. Если нет phone_number — возвращает дефолтный ответ
  2. Получает NetHunt credentials из D1
  3. Ищет запись в CRM: NethuntClient.searchRecords({ query: '"Телефон":<phone>' })
  4. Извлекает: Name, company, source
  5. Формирует персонализированное приветствие на украинском

3. webinar-call-events

Endpoint: POST /webhook/webinar-call-eventsRetries: 3

Обрабатывает события media_start и media_end от Voice Assistant. Управляет жизненным циклом звонка в БД.

Запрос:

json
{
  "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
}
ПараметрТипОбязательныйОписание
eventTypestringДа"media_start" или "media_end"
callIdstringДаVoice Assistant call ID
companyIdstringНетID компании
assistantIdstringНетID агента (для фильтрации)
assistantPhonestringНетТелефон агента
timestampstringНетВремя события
clientPhonestringНетТелефон клиента (только для media_end)
durationnumberНетДлительность в секундах (только media_end)

Логика media_start:

  1. Проверяет лимит активных звонков (max 3 со статусом in_call)
  2. Если лимит достигнут — возвращает ошибку
  3. Создаёт запись в sofa_calls со статусом in_call

Логика media_end:

  1. Ищет запись звонка по callId
  2. Если duration < 5 секунд — звонок считается неуспешным:
    • Статус → pending_retry
    • Инкремент retry_attempt
    • Рассчитывает next_retry_at (следующее окно: 10:40 / 13:40 / 16:40)
  3. Если 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

json
{
  "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"
    }
  }
}

Логика:

  1. Поиск записи звонка в БД (стратегия fallback):
    • По conversation_id
    • По phone_number со статусом waiting_postcall / pending_retry
    • Самая свежая запись (fallback)
  2. Определение статуса: если call_successful содержит "refused" или "відмова" → refused, иначе → completed
  3. Обновление записи в БД: статус, телефон, conversation_id, транскрипт, outcome, summary, duration
  4. Поиск в CRM: по полю "Телефон" в NetHunt
  5. Формирование комментария:
    • Дата/время, длительность, результат, резюме, транскрипция
    • Если уже есть комментарий — консолидация через Claude (объединение старых + новых записей)
    • Max длина: 2000 символов
  6. AI-анализ звонка (если транскрипт > 50 символов):
    • qualification: Холодный / Теплый / Горячий
    • salesProbability: 0-100
    • interestLevel: Низкий / Средний / Высокий
    • 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 минут)

Автоматически повторяет неуспешные звонки.

Логика:

  1. Запрашивает все звонки с status = 'pending_retry' и next_retry_at <= NOW
  2. Для каждого звонка:
    • Если retry_attempt >= 3 → статус failed, прекращает попытки
    • Иначе → вызывает SaasApiClient.originateCall(assistantId, phone)
    • При успехе: сохраняет новый va_call_id, статус → pending, обнуляет next_retry_at
    • При ошибке: логирует, переходит к следующему

Ответ:

json
{ "success": true, "processed": 2 }

Retry логика

Окна повторных звонков

Неуспешные звонки (duration < 5 сек) планируются на ближайшее окно:

ОкноВремя
110:40
213:40
316: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

sql
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
pendingRetry инициирован, ждёт media_start
completedУспешно завершён
refusedКлиент отказался
failedПревышен лимит retry (3 попытки)

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

Cloudflare Bindings (wrangler.toml)

BindingТипОписание
INTEG_DBD1База данных (credentials + sofa_calls)
INTEG_KVKVKV хранилище

Глобальные секреты (Doppler)

ПеременнаяОписание
CRYPTO_KEYКлюч шифрования credentials (AES-256-GCM)
CRYPTO_SALTСоль для PBKDF2 key derivation
CLAUDE_API_KEYAPI ключ Anthropic Claude (для AI-анализа)
SAAS_API_URLURL SaaS API (api.dev.happ.tools / api.happ.tools)
SAAS_ACCESS_TOKENТокен доступа к SaaS API

Per-integration секреты (D1 encrypted)

ПеременнаяОписание
NETHUNT_EMAILEmail для NetHunt API
NETHUNT_API_KEYAPI ключ NetHunt
NETHUNT_FOLDER_IDID папки в NetHunt CRM
SAAS_ASSISTANT_IDID ассистента для звонков через SaaS API

Как работает код

Entry Point (index.ts)

typescript
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)

typescript
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 };
  },
});

Тестирование

bash
# Запуск тестов
pnpm test

# Запуск с покрытием
pnpm test --coverage

Troubleshooting

Webhook не обрабатывается

  1. Проверьте что Worker запущен: curl https://integ.dev.happ.tools/sofa/health
  2. Проверьте логи: wrangler tail --env dev

Звонки не ретраятся

  1. Проверьте что cron работает: retry handler вызывается каждый час
  2. Проверьте записи в БД: SELECT * FROM sofa_calls WHERE status = 'pending_retry'
  3. Проверьте next_retry_at — должно быть <= текущего времени
  4. Проверьте retry_attempt < 3

AI-анализ не работает

  1. Проверьте CLAUDE_API_KEY в Doppler
  2. Транскрипт должен быть > 50 символов для запуска анализа
  3. Проверьте логи на ошибки парсинга JSON от Claude

Локальная разработка не работает

bash
# 1. Проверьте Doppler
doppler login
doppler configure

# 2. Запустите worker
pnpm start sofa

Связанные документы