Files
SKEEN-Proyecto/wacrm/src/lib/ai/auto-reply.ts
Consultoría Alcaraz Salazar a718592291 Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
2026-07-20 07:44:23 +00:00

138 lines
4.9 KiB
TypeScript

import { supabaseAdmin } from './admin-client'
import { loadAiConfig } from './config'
import { buildConversationContext } from './context'
import { retrieveKnowledge } from './knowledge'
import { generateReply } from './generate'
import { buildSystemPrompt } from './defaults'
import { latestUserMessage } from './query'
import { engineSendText } from '@/lib/flows/meta-send'
interface DispatchArgs {
/** Tenancy key — drives config, contact, and whatsapp_config lookups. */
accountId: string
conversationId: string
contactId: string
/** The account's WhatsApp config owner, used for the outbound send's
* audit columns (mirrors how the flow runner passes it through). */
configOwnerUserId: string
}
/**
* AI auto-reply for a freshly-arrived inbound message.
*
* Invoked from the WhatsApp webhook's `after()` block, only when no
* deterministic flow consumed the message (flows win). Mirrors the flow
* runner's contract: it owns its try/catch and NEVER throws — a failing
* or slow LLM call must not affect the webhook's 200 to Meta.
*
* Eligibility gates (any → silent no-op):
* - AI off / auto-reply disabled for the account
* - a human agent is assigned (they own the thread)
* - auto-reply was disabled for this conversation (prior handoff)
* - the per-conversation reply cap is reached
* - there's nothing to reply to
*
* The 24h WhatsApp session window is inherently open here — we're
* reacting to a customer message that just landed — so no separate
* window check is needed.
*/
export async function dispatchInboundToAiReply(
args: DispatchArgs,
): Promise<void> {
const { accountId, conversationId, contactId, configOwnerUserId } = args
try {
const db = supabaseAdmin()
const config = await loadAiConfig(db, accountId)
if (!config || !config.autoReplyEnabled) return
// Deterministic, user-configured responders win over the LLM — the
// caller already excludes messages a Flow consumed. Message-level
// automations (`new_message_received` / `keyword_match`) are
// dispatched independently for this same inbound and may send their
// own reply, so if the account has any active one we stand down to
// avoid double-texting the customer. (Relationship triggers like
// `first_inbound_message` don't count — they're not per-message
// auto-responders.)
const { data: autoResponders } = await db
.from('automations')
.select('id')
.eq('account_id', accountId)
.eq('is_active', true)
.in('trigger_type', ['new_message_received', 'keyword_match'])
.limit(1)
if (autoResponders && autoResponders.length > 0) return
const { data: conv, error: convErr } = await db
.from('conversations')
.select('assigned_agent_id, ai_autoreply_disabled, ai_reply_count')
.eq('id', conversationId)
.maybeSingle()
if (convErr || !conv) return
if (conv.assigned_agent_id) return // a human owns this thread
if (conv.ai_autoreply_disabled) return // handed off / turned off here
// Cheap early-out; the authoritative cap check is the atomic claim
// below (this read can race a concurrent inbound).
if (conv.ai_reply_count >= config.autoReplyMaxPerConversation) return
const messages = await buildConversationContext(db, conversationId)
if (messages.length === 0) return
// Ground the reply in the account's knowledge base (best-effort).
const knowledge = await retrieveKnowledge(
db,
accountId,
config,
latestUserMessage(messages),
)
const systemPrompt = buildSystemPrompt({
userPrompt: config.systemPrompt,
mode: 'auto_reply',
knowledge,
})
const { text, handoff } = await generateReply({
config,
systemPrompt,
messages,
})
if (handoff || !text) {
// The model can't (or shouldn't) answer — stop auto-replying on
// this thread and leave the inbound unanswered so it surfaces in
// the inbox for a human. Sticky until an admin re-enables.
await db
.from('conversations')
.update({ ai_autoreply_disabled: true })
.eq('id', conversationId)
return
}
// Atomically claim a reply slot: the cap check + increment happen in
// one UPDATE, so concurrent inbounds can never overshoot the cap. If
// another inbound just took the last slot, `claimed` is false and we
// skip the send. (We consume a slot slightly before the send lands —
// fail-safe: under-reply rather than over-reply.)
const { data: claimed, error: claimErr } = await db.rpc(
'claim_ai_reply_slot',
{
conversation_id: conversationId,
max_replies: config.autoReplyMaxPerConversation,
},
)
if (claimErr || claimed !== true) return
await engineSendText({
accountId,
userId: configOwnerUserId,
conversationId,
contactId,
text,
})
} catch (err) {
console.error('[ai auto-reply] dispatch failed:', err)
}
}