Handlers Reference
Полный справочник по написанию handlers для интеграций с использованием @happ-integ/core.
Что такое Handler
Handler — это функция, которая обрабатывает конкретное событие/действие в интеграции. Handlers определяются через defineHandler() и получают готовый контекст с клиентами (DI).
Принципы
- Один handler = одно действие — не смешивай несколько операций
- Dependency Injection — все клиенты приходят через
ctx - Типизация — используй generics для payload и response
- Декларативность — endpoint, retries, rate limit — в конфиге
- Обработка ошибок — всегда возвращай
{ success: false, error }при ошибке
Базовый синтаксис
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 получает контекст с готовыми клиентами:
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.
// 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.
// 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.
// 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.
// 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 интерфейсы
// 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 интерфейсы
// 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 интерфейсы
// 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:
// 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:
// 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:
# Все 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
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
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;
}Пример использования:
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 ✅
// ✅ Используй 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 ❌
// ❌ Не вызывай 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):
// 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):
// 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 | Метод | Описание |
|---|---|---|
/setup | POST | Инфраструктура: миграции БД, создание placeholder секретов |
/health | GET | Проверка здоровья: D1, KV, Creds |
Template Handlers (реализуются в каждой интеграции)
| Endpoint | Метод | Описание |
|---|---|---|
/webhook/call-originate | POST | Инициация исходящего звонка через VA |
/webhook/call-events | POST | Получение событий call_start/call_end от VA |
/webhook/agent-init | POST | Контекст клиента для агента |
/webhook/agent-postcall | POST | AI анализ + обновление 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иretryhandlers используй@happ-integ/call-queue. Пакет содержит всю логику очереди, concurrency-контроля и retry. Не пиши эту логику вручную. См. Integration Guide — Call Queue.
Четыре стандартных хендлера
| Handler | Endpoint | Описание |
|---|---|---|
call-originate | POST /webhook/call-originate | Инициация исходящего звонка через VA |
call-events | POST /webhook/call-events | Получение событий call_start/call_end от VA |
agent-init | POST /webhook/agent-init | Получение контекста клиента перед звонком |
agent-postcall | POST /webhook/agent-postcall | AI анализ транскрипции, обновление CRM |
1. call-originate
Инициирует исходящий звонок через SaaS API Voice Assistant.
Payload:
interface ICallOriginatePayload {
phone_number: string; // Номер телефона клиента
}Response:
interface ICallOriginateResponse {
success: boolean;
callId?: string; // ID созданного звонка
error?: string;
}Пример запроса:
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:
interface ICallEventsPayload {
eventType: "call_start" | "call_end";
callId: string;
conversation_id?: string;
duration?: number;
metadata?: Record<string, unknown>;
}Response:
interface ICallEventsResponse {
success: boolean;
status?: string;
scheduledRetry?: boolean;
nextRetryAt?: string;
message?: string;
error?: string;
}Пример запроса:
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:
interface IAgentInitPayload {
phone_number: string; // Номер телефона клиента
}Response (11labs format):
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.
Пример ответа:
{
"type": "conversation_initiation_client_data",
"dynamic_variables": {
"today_is": "21.01.2026",
"time": "14:39:12",
"weekday": "Вівторок",
"client_name": "Іван",
"company": "ТОВ Компанія",
"lead_source": "Facebook"
}
}Пример реализации:
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):
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:
interface IAgentPostcallResponse {
success: boolean;
message?: string;
error?: string;
}Пример payload от 11labs:
{
"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"
}
}
}
}Утилита для даты/времени
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 (обновлённый)
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()
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
interface IStepOptions {
/** Input для логирования (если отличается от аргументов функции) */
input?: unknown;
/** Трансформация результата перед логированием */
transform?: (result: unknown) => unknown;
/** Не логировать output (для secrets, больших объектов) */
skipOutput?: boolean;
/** Не падать при ошибке, вернуть default или undefined */
optional?: boolean;
/** Значение по умолчанию для optional шагов */
default?: unknown;
}Optional steps (soft-fail)
// Если 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:
// Старый способ (без логирования)
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):
{"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
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 ✅
// ✅ Используй 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 ❌
// ❌ Не используй старый 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!Связанные документы
- INTEGRATION_GUIDE — создание интеграций
- TESTING_GUIDE — тестирование handlers
- CRM_CLIENTS — работа с CRM клиентами
- DATABASE — работа с D1
- SECRETS — управление credentials
- @happ-integ/call-queue — очередь исходящих звонков