import { createServiceClient, getCorsHeaders, jsonResponse, readJsonBody, verifyInternalRequest, } from "../_shared/security.ts"; const MAX_BODY_BYTES = 16 * 1024; // Outcome enum — единственный источник истины для UI (см. docs/autocall-contract.md) const OUTCOMES = new Set([ "confirmed", "confirmed_kept_previous", "confirmed_silent", "no_answer", "no_input_day", "no_input_half", "operator", "opt_out", "invalid_number", "error", ]); type AutocallBody = { asterisk_uniqueid?: string; order_group_id?: string | null; campaign_type?: string; customer_phone?: string; attempt_no?: number; scenario?: string; call_type?: string; chosen_date?: string; call_started_at?: string; answered_at?: string | null; duration_sec?: number | null; sip_code?: number | null; channel_state?: string | null; dtmf_day?: number | null; dtmf_half?: number | null; changed?: boolean; outcome?: string; was_test_mode?: boolean; }; const DAY_LABEL: Record = { 1: "завтра", 2: "послезавтра" }; const HALF_LABEL: Record = { 1: "Первая половина дня", 2: "Вторая половина дня" }; function addDays(date: Date, days: number): string { const d = new Date(date); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); } /** День по-человечески: сегодня / завтра / послезавтра, иначе дата дд.мм.гггг. */ function relativeDayLabel(dateKey: string): string { const today = new Date(); for (const [offset, label] of [[0, "сегодня"], [1, "завтра"], [2, "послезавтра"]] as const) { if (addDays(today, offset) === dateKey) return label; } const [y, m, d] = dateKey.split("-"); return y && m && d ? `${d}.${m}.${y}` : dateKey; } Deno.serve(async (request) => { if (request.method === "OPTIONS") { const cors = getCorsHeaders(request, "private"); return cors ? new Response("ok", { headers: cors }) : jsonResponse({ error: "Origin not allowed" }, 403); } if (request.method !== "POST") { return jsonResponse({ error: "Method not allowed" }, 405); } const cors = getCorsHeaders(request, "private") || {}; try { const { body } = await readJsonBody(request, { maxBytes: MAX_BODY_BYTES }); await verifyInternalRequest(request, undefined, { secretEnvNames: ["INTEGRATION_WEBHOOK_SECRET", "AUTOCALL_WEBHOOK_SECRET"], tokenEnvNames: ["INTEGRATION_API_KEY"], }); if (!body.outcome || !OUTCOMES.has(body.outcome)) { return jsonResponse({ error: `invalid outcome: ${body.outcome}` }, 400, cors); } if (!body.asterisk_uniqueid) { return jsonResponse({ error: "asterisk_uniqueid is required" }, 400, cors); } if (!body.customer_phone) { return jsonResponse({ error: "customer_phone is required" }, 400, cors); } const supabase = createServiceClient(); const campaignType = body.campaign_type || "autocall_slot"; const attemptNo = body.attempt_no ?? 1; const scenario = body.scenario || "A"; const callType = body.call_type === "pkp" ? "pkp" : "dlv"; const wasTest = body.was_test_mode ?? false; // ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ── const { data: existing } = await supabase .from("autocall_log") .select("id") .eq("asterisk_uniqueid", body.asterisk_uniqueid) .maybeSingle(); if (existing) { // Уже записано (клиент бросил трубку после фиксации, AGI шлёт повторно // при следующем save). Обновляем outcome/слот, не плодя строки. await supabase.from("autocall_log").update({ outcome: body.outcome, dtmf_day: body.dtmf_day ?? null, dtmf_half: body.dtmf_half ?? null, changed: body.changed ?? false, was_test_mode: body.was_test_mode ?? false, }).eq("id", existing.id); return jsonResponse({ ok: true, updated: true }, 200, cors); } // ── Вставка в лог ── const { error: logError } = await supabase.from("autocall_log").insert({ order_group_id: body.order_group_id || null, campaign_type: campaignType, customer_phone: body.customer_phone, attempt_no: attemptNo, scenario, call_type: callType, call_started_at: body.call_started_at || new Date().toISOString(), answered_at: body.answered_at || null, duration_sec: body.duration_sec ?? null, sip_code: body.sip_code ?? null, channel_state: body.channel_state || null, dtmf_day: body.dtmf_day ?? null, dtmf_half: body.dtmf_half ?? null, changed: body.changed ?? false, outcome: body.outcome, was_test_mode: wasTest, asterisk_uniqueid: body.asterisk_uniqueid, }); if (logError) throw logError; // ── Найти заказ, если order_group_id не передан ── let orderGroupId = body.order_group_id || null; if (!orderGroupId) { const { data: group } = await supabase .from("order_groups") .select("id") .eq("customer_phone", body.customer_phone) .in("delivery_status", ["pending_confirmation", "agreed"]) .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); if (group) orderGroupId = group.id; } if (orderGroupId) { const { data: group } = await supabase .from("order_groups") .select("id, delivery_type, delivery_status, notification_status") .eq("id", orderGroupId) .single(); const isPickup = group?.delivery_type === "pickup"; // ── confirmed*: записываем слот ── if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) { // День называет сам диалог (chosen_date) — робот перечисляет конкретные // дни окна самовывоза, поэтому «1/2/3» само по себе дату не задаёт. // Если дата не пришла (старые call-файлы) — считаем по цифре дня. const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "") ? (body.chosen_date as string) : null; const dayOffset = body.dtmf_day === 3 ? 2 : body.dtmf_day === 2 ? 1 : 0; const deliveryDate = chosenDate || addDays(new Date(), body.dtmf_day ? dayOffset : 1); const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null; const dayLabel = chosenDate ? relativeDayLabel(chosenDate) : body.dtmf_day ? DAY_LABEL[body.dtmf_day] : "завтра"; const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`; const update: Record = { delivery_date_source: "autocall", delivery_status: "agreed", notification_status: "confirmed", delivery_date: deliveryDate, called: true, called_at: new Date().toISOString(), called_comment: comment, next_notification_check_at: null, }; if (timeSlot) update.delivery_time = timeSlot; if (isPickup) { update.pickup_date = deliveryDate; if (timeSlot) update.pickup_time_slot = timeSlot; } await supabase.from("order_groups").update(update).eq("id", orderGroupId); } // ── Эскалация ── else if (["operator", "opt_out", "invalid_number"].includes(body.outcome)) { const update: Record = { notification_status: "manual_required", called: true, called_at: new Date().toISOString(), called_comment: body.outcome === "operator" ? "Просил менеджера" : body.outcome === "opt_out" ? "Отказался от автозвонков" : "Неверный номер", }; if (body.outcome === "opt_out") update.autocall_opt_out = true; await supabase.from("order_groups").update(update).eq("id", orderGroupId); } // ── no_answer / no_input: решаем, эскалировать ли ── else { const { count: noInputCount } = await supabase .from("autocall_log") .select("id", { count: "exact", head: true }) .eq("order_group_id", orderGroupId) .in("outcome", ["no_input_day", "no_input_half"]); const { count: totalAttempts } = await supabase .from("autocall_log") .select("id", { count: "exact", head: true }) .eq("order_group_id", orderGroupId); const noInputEscalate = 2; // из настроек, но пока константа const maxAttempts = 5; // из настроек, но пока константа const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts; if (escalate) { await supabase.from("order_groups").update({ notification_status: "manual_required", called: true, called_at: new Date().toISOString(), called_comment: "Не дозвонились / не нажал — менеджер", }).eq("id", orderGroupId); } } } return jsonResponse({ ok: true }, 200, cors); } catch (e) { const status = (e as { status?: number }).status || 500; return jsonResponse({ ok: false, error: (e as Error).message }, status, cors); } });