/* |-------------------------------------------------------------------------- | Agência PSI — Shared hooks: WhatsApp opt-out + auto-reply |-------------------------------------------------------------------------- | Logica provider-agnostica compartilhada entre evolution-whatsapp-inbound | e twilio-whatsapp-inbound. Cada provider injeta seu proprio SendFn — | Evolution envia direto via API (sem deducao de credito), Twilio envolve | o envio em deducao atomica com rollback. |-------------------------------------------------------------------------- */ import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2' // Provider deve prover uma funcao de envio de texto puro export type SendFn = (phone: string, text: string) => Promise<{ ok: boolean; messageId?: string | null; error?: string }> export type ProviderLabel = 'evolution' | 'twilio' export function buildThreadKey(patientId: string | null, phone: string | null): string { if (patientId) return patientId return `anon:${phone ?? 'unknown'}` } export function normalizeForMatch(s: string | null | undefined): string { return String(s ?? '') .toLowerCase() .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/[^\p{L}\p{N}\s]/gu, ' ') .replace(/\s+/g, ' ') .trim() } // ═══════════════════════════════════════════════════════════════════════ // Opt-out (LGPD) // ═══════════════════════════════════════════════════════════════════════ export async function detectOptoutKeyword( supa: SupabaseClient, tenantId: string, body: string | null ): Promise { if (!body) return null const normalized = normalizeForMatch(body) if (!normalized) return null const { data } = await supa .from('conversation_optout_keywords') .select('keyword') .or(`tenant_id.is.null,tenant_id.eq.${tenantId}`) .eq('enabled', true) if (!data || !data.length) return null for (const row of data) { const kw = normalizeForMatch(row.keyword) if (!kw) continue if (normalized === kw) return row.keyword const pattern = new RegExp(`(^|\\s)${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`) if (pattern.test(normalized)) return row.keyword } return null } export async function isOptedOut(supa: SupabaseClient, tenantId: string, phone: string): Promise { const { data } = await supa .from('conversation_optouts') .select('id') .eq('tenant_id', tenantId) .eq('phone', phone) .is('opted_back_in_at', null) .limit(1) .maybeSingle() return !!data } const OPT_IN_KEYWORDS = ['voltar', 'retornar', 'reativar', 'restart'] export async function maybeOptIn( supa: SupabaseClient, tenantId: string, phone: string, body: string | null ): Promise { if (!body) return false const normalized = normalizeForMatch(body) if (!normalized) return false for (const kw of OPT_IN_KEYWORDS) { if (normalized === kw || new RegExp(`(^|\\s)${kw}(\\s|$)`).test(normalized)) { const { data } = await supa .from('conversation_optouts') .update({ opted_back_in_at: new Date().toISOString() }) .eq('tenant_id', tenantId) .eq('phone', phone) .is('opted_back_in_at', null) .select('id') .maybeSingle() return !!data } } return false } export async function registerOptout( supa: SupabaseClient, tenantId: string, phone: string, patientId: string | null, originalMessage: string | null, keywordMatched: string, provider: ProviderLabel, sendFn: SendFn ): Promise { const { data: existing } = await supa .from('conversation_optouts') .select('id') .eq('tenant_id', tenantId) .eq('phone', phone) .is('opted_back_in_at', null) .maybeSingle() if (existing) return await supa.from('conversation_optouts').insert({ tenant_id: tenantId, phone, patient_id: patientId, source: 'keyword', keyword_matched: keywordMatched, original_message: (originalMessage || '').slice(0, 500) }) const ackText = 'OK! Não enviaremos mais mensagens automáticas. Você ainda pode falar com seu terapeuta diretamente quando quiser. Para voltar a receber, envie VOLTAR.' try { const res = await sendFn(phone, ackText) if (res.ok) { await supa.from('conversation_messages').insert({ tenant_id: tenantId, patient_id: patientId, channel: 'whatsapp', direction: 'outbound', from_number: null, to_number: phone, body: ackText, provider, provider_message_id: res.messageId ?? null, provider_raw: { optout_ack: true }, kanban_status: 'resolved', responded_at: new Date().toISOString() }) } else { console.error('[optout] ack send failed:', res.error) } } catch (err) { console.error('[optout] ack send error:', err) } } // ═══════════════════════════════════════════════════════════════════════ // Auto-reply (schedule-aware, cooldown, respeita opt-out) // ═══════════════════════════════════════════════════════════════════════ export type ScheduleWindow = { dow: number; start: string; end: string } function hhmmToMinutes(s: string): number { const m = String(s).match(/^(\d{1,2}):(\d{2})/) if (!m) return -1 return parseInt(m[1], 10) * 60 + parseInt(m[2], 10) } function nowInSaoPaulo(): { dow: number; minutes: number } { const now = new Date() const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Sao_Paulo', weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false }) const parts = fmt.formatToParts(now) const weekday = parts.find((p) => p.type === 'weekday')?.value || 'Sun' const hour = parseInt(parts.find((p) => p.type === 'hour')?.value || '0', 10) const minute = parseInt(parts.find((p) => p.type === 'minute')?.value || '0', 10) const dowMap: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } return { dow: dowMap[weekday] ?? 0, minutes: hour * 60 + minute } } function isWithinWindows(windows: ScheduleWindow[]): boolean { if (!Array.isArray(windows) || !windows.length) return false const { dow, minutes } = nowInSaoPaulo() for (const w of windows) { if (w.dow !== dow) continue const start = hhmmToMinutes(w.start) const end = hhmmToMinutes(w.end) if (start < 0 || end < 0) continue if (minutes >= start && minutes < end) return true } return false } async function windowsFromAgenda(supa: SupabaseClient, tenantId: string): Promise { const { data, error } = await supa .from('agenda_regras_semanais') .select('dia_semana, hora_inicio, hora_fim, ativo') .eq('tenant_id', tenantId) .eq('ativo', true) if (error || !data) return [] return data.map((r) => ({ dow: r.dia_semana, start: String(r.hora_inicio).slice(0, 5), end: String(r.hora_fim).slice(0, 5) })) } export async function maybeSendAutoReply( supa: SupabaseClient, tenantId: string, threadKey: string, fromPhone: string | null, provider: ProviderLabel, sendFn: SendFn ): Promise<{ sent: boolean; reason?: string }> { if (!fromPhone) return { sent: false, reason: 'no_phone' } if (await isOptedOut(supa, tenantId, fromPhone)) { return { sent: false, reason: 'opted_out' } } const { data: settings } = await supa .from('conversation_autoreply_settings') .select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window') .eq('tenant_id', tenantId) .maybeSingle() if (!settings || !settings.enabled) return { sent: false, reason: 'disabled' } let withinHours = false if (settings.schedule_mode === 'agenda') { const windows = await windowsFromAgenda(supa, tenantId) withinHours = isWithinWindows(windows) } else if (settings.schedule_mode === 'business_hours') { withinHours = isWithinWindows((settings.business_hours as ScheduleWindow[]) || []) } else if (settings.schedule_mode === 'custom') { withinHours = isWithinWindows((settings.custom_window as ScheduleWindow[]) || []) } if (withinHours) return { sent: false, reason: 'within_hours' } if ((settings.cooldown_minutes ?? 0) > 0) { const cutoff = new Date(Date.now() - settings.cooldown_minutes * 60 * 1000).toISOString() const { data: recent } = await supa .from('conversation_autoreply_log') .select('sent_at') .eq('tenant_id', tenantId) .eq('thread_key', threadKey) .gte('sent_at', cutoff) .order('sent_at', { ascending: false }) .limit(1) .maybeSingle() if (recent) return { sent: false, reason: 'cooldown' } } const sendRes = await sendFn(fromPhone, settings.message) if (!sendRes.ok) { console.error('[auto-reply] send failed:', sendRes.error) return { sent: false, reason: 'send_failed' } } await supa.from('conversation_messages').insert({ tenant_id: tenantId, channel: 'whatsapp', direction: 'outbound', from_number: null, to_number: fromPhone, body: settings.message, provider, provider_message_id: sendRes.messageId ?? null, provider_raw: { auto_reply: true }, kanban_status: 'awaiting_patient', responded_at: new Date().toISOString() }) await supa.from('conversation_autoreply_log').insert({ tenant_id: tenantId, thread_key: threadKey }) return { sent: true } } // ═══════════════════════════════════════════════════════════════════════ // Twilio: send wrapper com deducao de credito + rollback // ═══════════════════════════════════════════════════════════════════════ export type TwilioChannel = { twilio_subaccount_sid: string twilio_phone_number: string credentials: { subaccount_auth_token?: string } } async function sendViaTwilioRaw( channel: TwilioChannel, toPhone: string, text: string ): Promise<{ ok: boolean; messageId?: string; error?: string }> { const subSid = channel.twilio_subaccount_sid const authToken = channel.credentials?.subaccount_auth_token const fromNumber = channel.twilio_phone_number if (!subSid || !authToken || !fromNumber) { return { ok: false, error: 'Twilio credenciais incompletas' } } const endpoint = `https://api.twilio.com/2010-04-01/Accounts/${subSid}/Messages.json` const basicAuth = btoa(`${subSid}:${authToken}`) const toE164 = toPhone.startsWith('+') ? toPhone : `+${toPhone}` const params = new URLSearchParams() params.append('From', `whatsapp:${fromNumber}`) params.append('To', `whatsapp:${toE164}`) params.append('Body', text) try { const resp = await fetch(endpoint, { method: 'POST', headers: { Authorization: `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) const data = await resp.json().catch(() => null) as Record | null if (!resp.ok) { return { ok: false, error: `Twilio ${resp.status}: ${(data?.message as string) || ''}` } } return { ok: true, messageId: String(data?.sid || '') } } catch (e) { return { ok: false, error: String(e) } } } // Cria SendFn que: // 1) deduz 1 credito do tenant via RPC atomica // 2) envia via Twilio; se falhar, refunda o credito // 3) retorna resultado ao caller export function makeTwilioCreditedSendFn( supa: SupabaseClient, tenantId: string, channel: TwilioChannel, noteLabel: string ): SendFn { return async (phone: string, text: string) => { const { error: dedErr } = await supa.rpc('deduct_whatsapp_credits', { p_tenant_id: tenantId, p_amount: 1, p_conversation_message_id: null, p_note: noteLabel }) if (dedErr) { const insufficient = String(dedErr.message || '').includes('insufficient_credits') return { ok: false, error: insufficient ? 'insufficient_credits' : dedErr.message } } const sendRes = await sendViaTwilioRaw(channel, phone, text) if (!sendRes.ok) { await supa.rpc('add_whatsapp_credits', { p_tenant_id: tenantId, p_amount: 1, p_kind: 'refund', p_purchase_id: null, p_admin_id: null, p_note: `Refund ${noteLabel}: ${(sendRes.error || '').slice(0, 180)}` }) return { ok: false, error: sendRes.error } } return { ok: true, messageId: sendRes.messageId ?? null } } }