Skip to content

Handlers Reference

Полный справочник по написанию handlers для интеграций с использованием @happ-integ/core.


Что такое Handler

Handler — это функция, которая обрабатывает конкретное событие/действие в интеграции. Handlers определяются через defineHandler() и получают готовый контекст с клиентами (DI).

Принципы

  1. Один handler = одно действие — не смешивай несколько операций
  2. Dependency Injection — все клиенты приходят через ctx
  3. Типизация — используй generics для payload и response
  4. Декларативность — endpoint, retries, rate limit — в конфиге
  5. Обработка ошибок — всегда возвращай { success: false, error } при ошибке

Базовый синтаксис

typescript
import { defineHandler } from "@happ-integ/core";

export const myHandler = defineHandler<TPayload, TResponse>({
  name: "my-handler",           // Уникальное имя
  retries: 3,                   // Retry (default: 3)
  endpoint: "POST /my-action",  // HTTP endpoint или false

  async handler({ payload, db, cache, creds, log, integrationName }) {
    // Бизнес-логика
    return { success: true };
  },
});

Handler Context

Каждый handler получает контекст с готовыми клиентами:

typescript
interface IHandlerContext<TPayload> {
  payload: TPayload;           // Данные запроса
  db: DB;                      // Клиент БД (D1/Neon)
  cache: Cache;                // Клиент кэша (KV)
  creds: Creds;                // Клиент credentials
  config: Config;              // KV-based configuration
  env: ICloudflareBindings;    // Cloudflare bindings
  log: ILogger;                // Logger (info/error)
  integrationName: string;     // Имя интеграции ("sofa", "keycrm", etc.)
}

interface ILogger {
  info: (message: string, data?: Record<string, unknown>) => void;
  error: (message: string, error?: unknown, data?: Record<string, unknown>) => void;
}

Структура файлов

integrations/{name}/src/
├── config/
│   ├── types.ts           # Интерфейсы конфигов
│   ├── defaults.ts        # Дефолтные значения конфигов
│   └── index.ts           # Barrel export
├── handlers/
│   ├── index.ts           # Barrel export всех handlers
│   ├── call-originate.ts  # Инициация исходящего звонка
│   ├── call-events.ts     # События от VA (call_start/call_end)
│   ├── agent-init.ts      # Контекст клиента для агента
│   ├── agent-postcall.ts  # AI анализ + CRM update
│   └── {action}.ts        # Дополнительные handlers
├── types.ts               # Интерфейсы payload и response
├── migrations/
│   └── index.ts           # SQL миграции
└── index.ts               # Entry point с createIntegration()

Типы handlers

1. Webhook Handler (с HTTP endpoint)

Handler с публичным HTTP endpoint.

typescript
// handlers/agent-init.ts
import { defineHandler } from "@happ-integ/core";
import type { IAgentInitPayload, IAgentInitResponse, ICredentials } from "../types";

export const agentInitHandler = defineHandler<IAgentInitPayload, IAgentInitResponse>({
  name: "agent-init",
  retries: 0,
  endpoint: "POST /webhook/agent-init",  // Создает POST /{integration}/webhook/agent-init

  async handler({ payload, cache, creds, log, integrationName }) {
    const { phone, conversationId } = payload;

    if (!phone || !conversationId) {
      log.info("Missing required fields");
      return getDefaultResponse();
    }

    log.info("Processing init webhook", { phone, conversationId });

    // Проверяем кэш
    const cached = await cache.get<IInitWebhookResponse>(`context:${phone}`);
    if (cached) {
      log.info("Found cached context");
      return cached;
    }

    // Получаем credentials
    const secrets = await creds.get<ICredentials>(integrationName);

    // ... бизнес-логика ...

    const response: IInitWebhookResponse = {
      firstMessage: "Привет! Чем могу помочь?",
      context: { clientName: "Клиент" },
    };

    // Кэшируем
    await cache.set(`context:${phone}`, response, 3600);

    return response;
  },
});

function getDefaultResponse(): IInitWebhookResponse {
  return {
    firstMessage: "Привет! Чем могу помочь?",
    context: { clientName: "Клиент" },
  };
}

2. Event-only Handler (без HTTP endpoint)

Handler без HTTP endpoint, вызывается только через event.

typescript
// handlers/retry.ts
import { defineHandler } from "@happ-integ/core";

interface IRetryPayload {
  callId?: string;
}

interface IRetryResponse {
  success: boolean;
  processed: number;
  error?: string;
}

export const retryHandler = defineHandler<IRetryPayload, IRetryResponse>({
  name: "retry",
  retries: 3,
  endpoint: false,  // Нет HTTP endpoint, только event

  async handler({ db, log }) {
    try {
      const now = new Date().toISOString();
      const callsToRetry = await db.select("calls", {
        next_retry_at: { $lte: now },
      } as Record<string, unknown>);

      if (!callsToRetry?.length) {
        return { success: true, processed: 0 };
      }

      let processed = 0;
      for (const call of callsToRetry) {
        try {
          await db.update("calls", { status: "initiated" }, { id: call.id });
          processed++;
        } catch (error) {
          log.error(`Failed to retry call ${call.id}`, error);
        }
      }

      return { success: true, processed };
    } catch (error) {
      log.error("Retry failed", error);
      return { success: false, processed: 0, error: String(error) };
    }
  },
});

3. Action Handler с лимитами

Handler с проверкой concurrent limits.

typescript
// handlers/test-call.ts
import { defineHandler } from "@happ-integ/core";
import type { ITestCallPayload, ITestCallResponse, ICredentials } from "../types";

const MAX_CONCURRENT_CALLS = 3;

export const testCallHandler = defineHandler<ITestCallPayload, ITestCallResponse>({
  name: "test-call",
  retries: 0,  // Без retry - пользователь сам повторит
  endpoint: "POST /test-call",

  async handler({ payload, db, creds, log, integrationName }) {
    const { phoneNumber, assistantId, authToken, sip } = payload;

    // 1. Валидация
    if (!phoneNumber) return { success: false, error: "phoneNumber is required" };
    if (!authToken) return { success: false, error: "authToken is required" };
    if (!sip) return { success: false, error: "sip is required" };

    try {
      // 2. Проверка лимитов
      const result = await db.query<{ count: number }>(
        `SELECT COUNT(*) as count FROM ${integrationName}_calls WHERE status IN ('initiated', 'in_progress')`
      );
      const activeCalls = result.data[0]?.count ?? 0;

      if (activeCalls >= MAX_CONCURRENT_CALLS) {
        log.info("Max concurrent calls reached", { activeCalls });
        return { success: false, error: `Max calls limit reached (${MAX_CONCURRENT_CALLS})` };
      }

      // 3. Получение настроек
      const secrets = await creds.get<ICredentials>(integrationName);
      const assistantId = secrets?.SAAS_ASSISTANT_ID;

      // 4. Создание записи
      const callId = `call_${Date.now()}`;
      await db.insert("calls", {
        id: callId,
        call_id: callId,
        status: "initiated",
        callee: phoneNumber,
        metadata: JSON.stringify({ assistantId, isTestCall: true }),
      });

      log.info("Call record created", { callId, phoneNumber });

      // 5. Вызов внешнего API
      const response = await fetch(`${baseUrl}/${sip}/originate/`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${authToken}`,
        },
        body: JSON.stringify({ phone_number: phoneNumber, assistant_id: assistantId }),
      });

      if (!response.ok) {
        await db.update("calls", { status: "failed" }, { id: callId });
        return { success: false, error: `API error: ${response.status}`, callId };
      }

      await db.update("calls", { status: "in_progress" }, { id: callId });

      return { success: true, callId, message: "Call initiated" };
    } catch (error) {
      log.error("Test call failed", error);
      return { success: false, error: String(error) };
    }
  },
});

4. CRM Integration Handler

Handler для интеграции с CRM.

typescript
// handlers/nethunt.ts
import { defineHandler } from "@happ-integ/core";
import { NethuntClient } from "@happ-integ/nethunt";
import type { INethuntPayload, ICredentials } from "../types";

export const nethuntHandler = defineHandler<INethuntPayload, { success: boolean; callId?: string; error?: string }>({
  name: "nethunt",
  retries: 3,
  endpoint: false,  // Вызывается через webhook/:action

  async handler({ payload, db, cache, creds, log, integrationName }) {
    try {
      if (!payload.recordId || !payload.phone) {
        return { success: false, error: "recordId and phone are required" };
      }

      // Получение credentials
      const secrets = await creds.get<ICredentials>(integrationName);
      const { NETHUNT_EMAIL, NETHUNT_API_KEY } = secrets || {};

      if (!NETHUNT_EMAIL || !NETHUNT_API_KEY) {
        return { success: false, error: "NetHunt credentials not configured" };
      }

      // Инициализация клиента
      const nethunt = new NethuntClient(NETHUNT_EMAIL, NETHUNT_API_KEY);

      // Получение данных с кэшированием
      const record = await cache.cacheAside(
        `crm:record:${payload.recordId}`,
        3600,
        () => nethunt.getRecord(payload.recordId)
      );

      if (!record) {
        return { success: false, error: "CRM record not found" };
      }

      // Создание записи
      const callId = `call_${Date.now()}`;
      await db.insert("calls", {
        id: callId,
        phone: payload.phone,
        lead_id: payload.recordId,
        status: "initiated",
      });

      log.info("Call created from CRM", { callId, recordId: payload.recordId });

      return { success: true, callId };
    } catch (error) {
      log.error("Nethunt handler failed", error);
      return { success: false, error: String(error) };
    }
  },
});

Типизация

Payload интерфейсы

typescript
// types.ts

// Base payload
export interface IBasePayload {
  _receivedAt?: string;
  _action?: string;
}

// Init Webhook
export interface IInitWebhookPayload extends IBasePayload {
  phone: string;
  conversationId: string;
  metadata?: Record<string, unknown>;
}

// Post Webhook
export interface IPostWebhookPayload extends IBasePayload {
  conversationId: string;
  phone?: string;
  transcript?: string;
  outcome: "completed" | "transferred" | "no_answer" | "busy" | "failed";
  duration?: number;
}

// Test Call
export interface ITestCallPayload extends IBasePayload {
  phoneNumber: string;
  assistantId?: number;
  authToken: string;
  sip: string;
}

Response интерфейсы

typescript
// types.ts

// Base response
export interface IBaseResponse {
  success: boolean;
  error?: string;
}

// Init Webhook Response
export interface IInitWebhookResponse {
  firstMessage: string;
  context: {
    clientName: string;
    company?: string;
    email?: string;
    [key: string]: unknown;
  };
}

// Post Webhook Response
export interface IPostWebhookResponse extends IBaseResponse {
  message?: string;
  recordId?: string;
}

// Test Call Response
export interface ITestCallResponse extends IBaseResponse {
  callId?: string;
  message?: string;
}

Credentials интерфейсы

typescript
// types.ts

export interface ICredentials {
  // SaaS API
  SAAS_ASSISTANT_ID: string;

  // Add your CRM credentials here, e.g.:
  // NETHUNT_EMAIL?: string;
  // NETHUNT_API_KEY?: string;
}

Barrel Export

Все handlers должны экспортироваться через handlers/index.ts:

typescript
// handlers/index.ts
export { callOriginateHandler } from "./call-originate";
export { callEventsHandler } from "./call-events";
export { agentInitHandler } from "./agent-init";
export { agentPostcallHandler } from "./agent-postcall";
export { retryHandler } from "./retry";  // Опционально, для cron retry

И импортироваться в index.ts:

typescript
// index.ts
import { createIntegration } from "@happ-integ/core";
import * as handlers from "./handlers";
import { MIGRATIONS } from "./migrations";
import { DEFAULT_CONFIGS } from "./config";

export default createIntegration({
  name: "my-integration",
  handlers: Object.values(handlers),  // Автоматически подхватывает все handlers
  migrations: MIGRATIONS,
  secrets: ["API_KEY", "API_SECRET"],
  defaultConfigs: DEFAULT_CONFIGS,
  // Cron задачи — вызов handler по расписанию
  scheduled: [
    { cron: "0 * * * *", handlerName: "retry", payload: { triggered_by: "cron" } },
  ],
});

Scheduled (Cron) Handlers

Handler может вызываться по cron расписанию. Для этого он добавляется в scheduled массив в createIntegration(). Handler должен уже быть в handlers[]. Один handler может одновременно иметь HTTP endpoint и cron trigger.

При наличии scheduled в конфиге, core автоматически создает POST /cron/trigger endpoint (через @happ-integ/cron). Это позволяет вызывать scheduled handlers через gateway:

bash
# Все scheduled handlers
curl -X POST http://localhost:3001/{integration}/cron/trigger

# Конкретный handler
curl -X POST http://localhost:3001/{integration}/cron/trigger \
  -H "Content-Type: application/json" \
  -d '{"handlerName": "retry"}'

Лимит Cloudflare (Paid Plan): 5 cron triggers на worker.

Подробнее: ARCHITECTURE.md — Scheduled Tasks


Handler Config Options

typescript
interface IHandlerConfig<TPayload, TResponse> {
  // Обязательные
  name: string;                              // Уникальное имя (snake-case или kebab-case)
  handler: (ctx: IHandlerContext<TPayload>) => Promise<TResponse>;

  // Опциональные
  retries?: number;                          // Retry (0-20, default: 3)
  endpoint?: `${HttpMethod} /${string}` | false;  // HTTP endpoint
  dedup?: {
    ttlSeconds: number;                      // TTL дедупликации
    keyField?: string;                       // Поле для ключа (default: id/recordId/conversationId)
  };
  openapi?: IHandlerOpenAPIConfig;           // OpenAPI документация (см. ниже)
}

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

OpenAPI конфиг для handler

typescript
interface IHandlerOpenAPIConfig {
  summary?: string;           // Краткое описание (отображается в списке)
  description?: string;       // Полное описание
  tags?: string[];            // Теги для группировки (default: ["Webhooks"])
  deprecated?: boolean;       // Помечает endpoint как устаревший
  requestBody?: {
    description?: string;
    required?: boolean;
    schema?: IOpenAPISchema;  // JSON Schema для request body
  };
  responses?: {
    [statusCode: string]: {
      description: string;
      schema?: IOpenAPISchema;
    };
  };
}

interface IOpenAPISchema {
  type?: string;              // "object", "string", "number", "boolean", "array"
  properties?: Record<string, IOpenAPISchema>;
  items?: IOpenAPISchema;     // Для type: "array"
  required?: string[];
  description?: string;
  example?: unknown;
  enum?: unknown[];
  format?: string;            // "date-time", "email", "uri", etc.
  nullable?: boolean;
}

Пример использования:

typescript
export const myHandler = defineHandler({
  name: "create-order",
  endpoint: "POST /webhook/create-order",
  openapi: {
    summary: "Create new order",
    description: "Creates a new order and sends it to CRM",
    tags: ["Orders"],
    requestBody: {
      description: "Order details",
      schema: {
        type: "object",
        required: ["phone", "product_id"],
        properties: {
          phone: { type: "string", description: "Customer phone", example: "+380991234567" },
          product_id: { type: "string", description: "Product ID" },
          quantity: { type: "number", description: "Quantity", example: 1 },
        },
      },
    },
    responses: {
      "200": {
        description: "Order created successfully",
        schema: {
          type: "object",
          properties: {
            success: { type: "boolean" },
            orderId: { type: "string" },
          },
        },
      },
    },
  },
  async handler({ payload }) {
    // ...
  },
});

Best Practices

DO ✅

typescript
// ✅ Используй destructuring для payload
async handler({ payload, db, cache, config, creds, log, integrationName }) {

// ✅ Валидируй payload в начале
if (!payload.phone) {
  return { success: false, error: "phone is required" };
}

// ✅ Читай конфиг из KV с fallback на дефолт
const crmFields = (await config.get<ICrmFieldsConfig>("crm_fields")) ?? DEFAULT_CRM_FIELDS;
const callHandling = (await config.get<ICallHandlingConfig>("call_handling")) ?? DEFAULT_CALL_HANDLING;

// ✅ Используй значения из конфига, а не хардкод
const query = `"${crmFields.phone}":${phone_number}`;
if (activeCalls >= callHandling.concurrency_limit) { ... }

// ✅ Используй typed credentials
const secrets = await creds.get<ICredentials>(integrationName);

// ✅ Логируй важные события
log.info("Processing request", { phone: payload.phone });

// ✅ Используй integrationName для prefix
await cache.set(`${integrationName}:context:${phone}`, data, 3600);

// ✅ Используй cacheAside для внешних API
const data = await cache.cacheAside(`key:${id}`, 3600, () => api.getData(id));

// ✅ Возвращай структурированный ответ
return { success: true, message: "Done", data };

DON'T ❌

typescript
// ❌ Не вызывай getDb() напрямую - используй ctx.db
const db = getDb();  // Плохо
const { db } = ctx;  // Хорошо

// ❌ Не хардкодь значения бизнес-логики
const MAX_CALLS = 3;  // Плохо — вынеси в config
if (activeCalls >= callHandling.concurrency_limit)  // Хорошо — из конфига

// ❌ Не хардкодь названия полей CRM
record.fields?.["Телефон"];  // Плохо
record.fields?.[crmFields.phone];  // Хорошо — из конфига

// ❌ Не игнорируй ошибки
try { ... } catch { }  // Плохо
catch (error) { log.error("Failed", error); return { success: false }; }  // Хорошо

// ❌ Не хардкодь integration name
await creds.get<ICredentials>("sofa");  // Плохо
await creds.get<ICredentials>(integrationName);  // Хорошо

// ❌ Не используй console.log
console.log("Debug");  // Плохо
log.info("Debug");     // Хорошо

// ❌ Не возвращай void
return;  // Плохо - всегда возвращай объект с success

Миграция со старого API

Было (старый API):

typescript
// handlers/init.ts
import { getDb, getCreds, getEnv } from "../utils/clients";
import { logInfo, logError } from "../utils/logger";

export async function handleInit(payload: IInitPayload) {
  const db = getDb();
  const creds = getCreds();
  const env = getEnv();

  logInfo("init", "Starting", payload);
  // ...
}

Стало (новый API):

typescript
// handlers/init.ts
import { defineHandler } from "@happ-integ/core";

export const initHandler = defineHandler<IInitPayload, IInitResponse>({
  name: "init",
  retries: 3,
  endpoint: false,
  async handler({ payload, db, creds, env, log }) {
    log.info("Starting", { payload });
    // ... всё то же самое, но клиенты уже в ctx
  },
});

// index.ts (~15 строк)
import { createIntegration } from "@happ-integ/core";
import * as handlers from "./handlers";

export default createIntegration({
  name: "my-integration",
  handlers: Object.values(handlers),
});

Handler Lifecycle (6 стандартных методов)

Каждая интеграция имеет 6 стандартных методов:

Built-in (реализованы в @happ-integ/core)

EndpointМетодОписание
/setupPOSTИнфраструктура: миграции БД, создание placeholder секретов
/healthGETПроверка здоровья: D1, KV, Creds

Template Handlers (реализуются в каждой интеграции)

EndpointМетодОписание
/webhook/call-originatePOSTИнициация исходящего звонка через VA
/webhook/call-eventsPOSTПолучение событий call_start/call_end от VA
/webhook/agent-initPOSTКонтекст клиента для агента
/webhook/agent-postcallPOSTAI анализ + обновление CRM

Sequence Diagram

┌──────────┐      ┌──────────┐      ┌──────────┐      ┌──────────┐
│  CRM     │      │Integration│     │Voice     │      │ 11labs   │
│ Trigger  │      │ Worker    │     │Assistant │      │ Agent    │
└────┬─────┘      └─────┬─────┘     └────┬─────┘      └────┬─────┘
     │                  │                │                 │
     │ POST call-originate │             │                 │
     │─────────────────>│                │                 │
     │                  │ originate call │                 │
     │                  │───────────────>│                 │
     │                  │                │                 │
     │                  │                │  GET agent-init │
     │                  │<───────────────────────────────────
     │                  │  {dynamic_variables}             │
     │                  │──────────────────────────────────>│
     │                  │                │                 │
     │                  │                │   ... call ...  │
     │                  │                │                 │
     │                  │ POST call-events│                │
     │                  │<───────────────│                 │
     │                  │ {session_status}                 │
     │                  │                │                 │
     │                  │                │ POST agent-postcall
     │                  │<───────────────────────────────────
     │                  │  {transcript, analysis}          │
     │                  │                │                 │
     │   CRM update     │                │                 │
     │<─────────────────│                │                 │

11labs Voice Handlers

Стандартные handlers для голосовых интеграций с 11labs Voice AI.

Исходящие звонки: Для call-originate, call-events и retry handlers используй @happ-integ/call-queue. Пакет содержит всю логику очереди, concurrency-контроля и retry. Не пиши эту логику вручную. См. Integration Guide — Call Queue.

Четыре стандартных хендлера

HandlerEndpointОписание
call-originatePOST /webhook/call-originateИнициация исходящего звонка через VA
call-eventsPOST /webhook/call-eventsПолучение событий call_start/call_end от VA
agent-initPOST /webhook/agent-initПолучение контекста клиента перед звонком
agent-postcallPOST /webhook/agent-postcallAI анализ транскрипции, обновление CRM

1. call-originate

Инициирует исходящий звонок через SaaS API Voice Assistant.

Payload:

typescript
interface ICallOriginatePayload {
  phone_number: string;  // Номер телефона клиента
}

Response:

typescript
interface ICallOriginateResponse {
  success: boolean;
  callId?: string;   // ID созданного звонка
  error?: string;
}

Пример запроса:

bash
curl -X POST https://integ.happ.tools/{integration}/webhook/call-originate \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+380994829573"}'

2. call-events (VA Events)

Получает события media_start/media_end от Voice Assistant. При media_end с duration < 5s — планирует retry, при duration >= 5s — ставит статус waiting_postcall.

Payload:

typescript
interface ICallEventsPayload {
  eventType: "call_start" | "call_end";
  callId: string;
  conversation_id?: string;
  duration?: number;
  metadata?: Record<string, unknown>;
}

Response:

typescript
interface ICallEventsResponse {
  success: boolean;
  status?: string;
  scheduledRetry?: boolean;
  nextRetryAt?: string;
  message?: string;
  error?: string;
}

Пример запроса:

bash
curl -X POST https://integ.happ.tools/{integration}/webhook/call-events \
  -H "Content-Type: application/json" \
  -d '{"eventType": "call_end", "callId": "call_abc123", "duration": 45}'

3. agent-init

Возвращает данные клиента в формате 11labs conversation_initiation_client_data.

Payload:

typescript
interface IAgentInitPayload {
  phone_number: string;  // Номер телефона клиента
}

Response (11labs format):

typescript
interface IAgentInitResponse {
  type: "conversation_initiation_client_data";
  dynamic_variables: {
    today_is: string;      // "21.01.2026"
    time: string;          // "14:39:12"
    weekday: string;       // "Середа"
    client_name?: string;  // "Іван"
    company?: string;      // "ТОВ Компанія"
    lead_source?: string;  // "Facebook"
    [key: string]: unknown;
  };
}

Note: first_message не передаётся — агент формирует его самостоятельно из своих настроек на платформе VA.

Пример ответа:

json
{
  "type": "conversation_initiation_client_data",
  "dynamic_variables": {
    "today_is": "21.01.2026",
    "time": "14:39:12",
    "weekday": "Вівторок",
    "client_name": "Іван",
    "company": "ТОВ Компанія",
    "lead_source": "Facebook"
  }
}

Пример реализации:

typescript
import { defineHandler } from "@happ-integ/core";
import { NethuntClient } from "@happ-integ/nethunt";
import type { ICrmFieldsConfig } from "../config";
import { DEFAULT_CRM_FIELDS } from "../config";

const LOCALE = "uk-UA";
const WEEKDAYS_UK = ["Неділя", "Понеділок", "Вівторок", "Середа", "Четвер", "П'ятниця", "Субота"];

function getDateTimeInfo() {
  const now = new Date();
  return {
    today_is: now.toLocaleDateString(LOCALE),
    time: now.toLocaleTimeString(LOCALE),
    weekday: WEEKDAYS_UK[now.getDay()],
  };
}

export const agentInitHandler = defineHandler<IAgentInitPayload, IAgentInitResponse>({
  name: "agent-init",
  retries: 0,
  endpoint: "POST /webhook/agent-init",
  async handler({ payload, config, creds, integrationName }) {
    const { phone_number } = payload;

    if (!phone_number) {
      return {
        type: "conversation_initiation_client_data",
        dynamic_variables: getDateTimeInfo(),
      };
    }

    const crmFields = (await config.get<ICrmFieldsConfig>("crm_fields")) ?? DEFAULT_CRM_FIELDS;
    const secrets = await creds.get<ICredentials>(integrationName);
    const nethunt = new NethuntClient(secrets.NETHUNT_EMAIL, secrets.NETHUNT_API_KEY);
    const records = await nethunt.searchRecords({
      folderId: secrets.NETHUNT_FOLDER_ID,
      query: `"${crmFields.phone}":${phone_number}`,
    });

    if (!records || records.length === 0) {
      return {
        type: "conversation_initiation_client_data",
        dynamic_variables: getDateTimeInfo(),
      };
    }

    const record = records[0];
    return {
      type: "conversation_initiation_client_data",
      dynamic_variables: {
        ...getDateTimeInfo(),
        client_name: record.fields?.[crmFields.name],
        company: record.fields?.[crmFields.company],
        lead_source: record.fields?.[crmFields.source],
      },
    };
  },
});

4. agent-postcall

Обрабатывает результаты звонка в формате 11labs post_call_transcription. Выполняет AI анализ и обновляет CRM.

Payload (11labs format):

typescript
interface IAgentPostcallPayload {
  type: "post_call_transcription";
  event_timestamp: number;
  data: {
    agent_id: string;
    conversation_id: string;
    status: string;
    transcript: Array<{
      role: "user" | "agent";
      message: string;
      time_in_call_secs: number;
    }>;
    metadata: {
      call_duration_secs: number;
      termination_reason: string;
    };
    analysis: {
      call_successful: string;
      transcript_summary: string;
    };
    conversation_initiation_client_data: {
      dynamic_variables?: {
        phone_number?: string;
        [key: string]: unknown;
      };
    };
  };
}

Response:

typescript
interface IAgentPostcallResponse {
  success: boolean;
  message?: string;
  error?: string;
}

Пример payload от 11labs:

json
{
  "type": "post_call_transcription",
  "event_timestamp": 1705845552,
  "data": {
    "agent_id": "agent_abc123",
    "conversation_id": "conv_xyz789",
    "status": "done",
    "transcript": [
      { "role": "agent", "message": "Привіт, Іван! Як справи?", "time_in_call_secs": 0 },
      { "role": "user", "message": "Добре, дякую!", "time_in_call_secs": 2.5 }
    ],
    "metadata": {
      "call_duration_secs": 45,
      "termination_reason": "user_ended_call"
    },
    "analysis": {
      "call_successful": "yes",
      "transcript_summary": "Клієнт підтвердив участь у вебінарі"
    },
    "conversation_initiation_client_data": {
      "dynamic_variables": {
        "phone_number": "+380994829573"
      }
    }
  }
}

Утилита для даты/времени

typescript
const WEEKDAYS_UK = ["Неділя", "Понеділок", "Вівторок", "Середа", "Четвер", "П'ятниця", "Субота"];

function getDateTimeInfo() {
  const now = new Date();
  return {
    today_is: now.toLocaleDateString("uk-UA"),  // "21.01.2026"
    time: now.toLocaleTimeString("uk-UA"),       // "14:39:12"
    weekday: WEEKDAYS_UK[now.getDay()],          // "Вівторок"
  };
}

Step-based Logging

Структурированное логирование через step.run() для полной видимости выполнения handlers.

Handler Context (обновлённый)

typescript
interface IHandlerContext<TPayload> {
  payload: TPayload;
  db: DB;
  cache: Cache;
  creds: Creds;
  config: Config;
  env: ICloudflareBindings;
  integrationName: string;

  /** @deprecated Use step.run() for structured logging */
  log: ILogger;

  /** Step runner for structured logging with automatic timing and error handling */
  step: IStepRunner;

  /** Log context for advanced tracing - pass to instrumented clients */
  ctx: ILogContext;
}

Базовое использование step.run()

typescript
import { defineHandler } from "@happ-integ/core";
import { NethuntClient } from "@happ-integ/nethunt";

export const myHandler = defineHandler({
  name: "my-handler",
  retries: 3,
  endpoint: "POST /my-action",

  async handler({ payload, step, ctx, creds, integrationName }) {
    // 1. Логирует input/output автоматически
    const secrets = await step.run(
      "creds.get",
      () => creds.get<ICredentials>(integrationName),
      { skipOutput: true }  // Не логировать secrets
    );

    // 2. Создаём instrumented client с ctx
    const nethunt = NethuntClient.create(secrets.EMAIL, secrets.API_KEY, ctx);

    // 3. Каждый вызов клиента логируется автоматически
    const records = await step.run(
      "crm.search",
      () => nethunt.searchRecords({ folderId: "123", query: "+380..." }),
      { input: { phone: "+380..." } }  // Явно указать input для логов
    );

    // 4. Update с transform для output
    await step.run(
      "crm.update",
      () => nethunt.updateRecord(records[0].id, { status: "called" }),
      {
        input: { recordId: records[0].id },
        transform: () => ({ updated: true }),  // Упростить output в логах
        skipOutput: true
      }
    );

    return { success: true };
  },
});

IStepOptions

typescript
interface IStepOptions {
  /** Input для логирования (если отличается от аргументов функции) */
  input?: unknown;

  /** Трансформация результата перед логированием */
  transform?: (result: unknown) => unknown;

  /** Не логировать output (для secrets, больших объектов) */
  skipOutput?: boolean;

  /** Не падать при ошибке, вернуть default или undefined */
  optional?: boolean;

  /** Значение по умолчанию для optional шагов */
  default?: unknown;
}

Optional steps (soft-fail)

typescript
// Если cache упал - не критично, продолжаем
const cached = await step.run(
  "cache.get",
  () => cache.get<IResponse>(`key:${phone}`),
  { optional: true }  // Вернёт undefined при ошибке
);

// Или с default значением
const config = await step.run(
  "config.load",
  () => loadConfig(),
  { optional: true, default: { retries: 3 } }
);

Instrumented Clients

Пакеты @happ-integ/nethunt, @happ-integ/happ-voice поддерживают auto-logging через ctx:

typescript
// Старый способ (без логирования)
const nethunt = new NethuntClient(email, apiKey);

// Новый способ (с auto-logging)
const nethunt = NethuntClient.create(email, apiKey, ctx);

// Каждый вызов автоматически создаёт span и логирует input/output:
// [DEBUG] nethunt.searchRecords:input { folderId: "123", query: "..." }
// [DEBUG] nethunt.searchRecords:output { count: 5 }

Output в разных средах

Локальная разработка (ConsoleTransport):

┌─ sofa/webinar-agent-init [trace-abc123] ────────────────────
│ 14:39:12.345 [INFO] handler:start { phone: "+380..." }

│ ├─ cache.get (2ms)
│ │  └─ input: { key: "sofa:context:+380..." }
│ │  └─ output: null

│ ├─ creds.get (1ms) [skipOutput]

│ ├─ crm.search (145ms)
│ │  └─ input: { phone: "+380..." }
│ │  └─ output: { records: 1 }

│ 14:39:12.495 [INFO] handler:end (150ms)
└──────────────────────────────────────────────────────────────

Production (JsonTransport → CF Observability):

json
{"level":"info","traceId":"abc123","handler":"webinar-agent-init","event":"handler:start","data":{"phone":"+380..."}}
{"level":"debug","traceId":"abc123","span":"cache.get","event":"input","data":{"key":"sofa:context:+380..."}}
{"level":"debug","traceId":"abc123","span":"cache.get","event":"output","duration_ms":2,"data":null}
{"level":"debug","traceId":"abc123","span":"crm.search","event":"input","data":{"phone":"+380..."}}
{"level":"debug","traceId":"abc123","span":"crm.search","event":"output","duration_ms":145,"data":{"records":1}}
{"level":"info","traceId":"abc123","handler":"webinar-agent-init","event":"handler:end","duration_ms":150}

Полный пример с tracing

typescript
import { defineHandler } from "@happ-integ/core";
import { NethuntClient } from "@happ-integ/nethunt";
import { HappVoiceClient } from "@happ-integ/happ-voice";
import type { ICallOriginatePayload, ICallOriginateResponse, ICredentials } from "../types";

export const callOriginateHandler = defineHandler<ICallOriginatePayload, ICallOriginateResponse>({
  name: "call-originate",
  retries: 3,
  endpoint: "POST /webhook/call-originate",

  async handler({ payload, step, ctx, db, creds, integrationName }) {
    const { sip, phone_number } = payload;

    if (!phone_number) {
      return { success: false, error: "phone_number is required" };
    }

    // Get credentials (skipOutput - не логируем secrets)
    const secrets = await step.run(
      "creds.get",
      () => creds.get<ICredentials>(integrationName),
      { skipOutput: true }
    );

    // Create instrumented clients
    const voice = HappVoiceClient.create({
      baseUrl: env.SAAS_API_URL,
      sipId: sip,
      ctx,  // <-- передаём ctx для auto-logging
    });

    // Initiate call (auto-logged by voice client)
    const result = await step.run(
      "voice.originate",
      () => voice.createCall({ phone: phone_number }),
      { input: { phone_number } }
    );

    // Save to DB
    // NOTE: db.insert() automatically adds project prefix, so use "calls" not "sofa_calls"
    const callId = `call_${Date.now()}`;
    await step.run(
      "db.insert",
      () => db.insert("calls", {
        id: callId,
        call_id: result.callId,
        phone: phone_number,
        status: "initiated",
      }),
      { input: { callId, phone_number } }
    );

    return { success: true, callId };
  },
});

Best Practices для logging

DO ✅

typescript
// ✅ Используй step.run() для всех внешних операций
await step.run("crm.search", () => nethunt.searchRecords(params));

// ✅ Передавай ctx в instrumented clients
const nethunt = NethuntClient.create(email, key, ctx);

// ✅ Скрывай secrets в логах
await step.run("creds.get", () => creds.get(name), { skipOutput: true });

// ✅ Используй optional для некритичных операций
await step.run("cache.get", () => cache.get(key), { optional: true });

// ✅ Указывай явный input когда он отличается от параметров
await step.run("crm.update", () => nethunt.updateRecord(id, data), {
  input: { recordId: id }  // Не логируем весь data объект
});

DON'T ❌

typescript
// ❌ Не используй старый log.info/error для операций
log.info("Searching CRM...");
const records = await nethunt.searchRecords(params);  // Нет timing!

// ❌ Не создавай клиенты без ctx
const nethunt = new NethuntClient(email, key);  // Нет auto-logging

// ❌ Не логируй sensitive данные
await step.run("creds.get", () => creds.get(name));  // Логирует secrets!

// ❌ Не оборачивай простые синхронные операции
await step.run("build.id", () => `call_${Date.now()}`);  // Overhead!

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