Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré / juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts, sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle week-end + placeholder « en attente du quart », clustering + lasso + filtre-date sur la carte, accès rapide « À assigner » (badge). Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive + timeout fetch global + authFetch durci → fin des rechargements manuels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
535 lines
22 KiB
JavaScript
535 lines
22 KiB
JavaScript
'use strict'
|
|
const { WebSocket } = require('ws')
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody, lookupCustomerByPhone, createCommunication, erpFetch, readJsonFile } = require('./helpers')
|
|
const { execTool } = require('./agent')
|
|
const sse = require('./sse')
|
|
|
|
// Gemini Live API WebSocket endpoint
|
|
const GEMINI_WS = 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
|
|
const LIVE_MODEL = cfg.VOICE_MODEL || 'models/gemini-2.5-flash-live-preview'
|
|
|
|
// Active voice sessions: callSid → session
|
|
const sessions = new Map()
|
|
|
|
// ── Audio conversion: Twilio mulaw 8kHz ↔ Gemini PCM16 16kHz/24kHz ──
|
|
|
|
// mulaw → linear PCM16 lookup table (ITU-T G.711)
|
|
const MULAW_TO_LINEAR = new Int16Array(256)
|
|
;(() => {
|
|
for (let i = 0; i < 256; i++) {
|
|
let mu = ~i & 0xFF
|
|
const sign = (mu & 0x80) ? -1 : 1
|
|
const exponent = (mu >> 4) & 0x07
|
|
const mantissa = mu & 0x0F
|
|
let magnitude = ((mantissa << 1) + 33) << (exponent + 2)
|
|
magnitude -= 0x84
|
|
MULAW_TO_LINEAR[i] = sign * magnitude
|
|
}
|
|
})()
|
|
|
|
// linear PCM16 → mulaw lookup (bias method)
|
|
function linearToMulaw (sample) {
|
|
const BIAS = 0x84, CLIP = 32635
|
|
const sign = (sample >> 8) & 0x80
|
|
if (sign) sample = -sample
|
|
if (sample > CLIP) sample = CLIP
|
|
sample += BIAS
|
|
let exponent = 7
|
|
const expMask = 0x4000
|
|
for (; exponent > 0; exponent--) {
|
|
if (sample & (expMask >> (7 - exponent))) break
|
|
}
|
|
const mantissa = (sample >> (exponent + 3)) & 0x0F
|
|
return ~(sign | (exponent << 4) | mantissa) & 0xFF
|
|
}
|
|
|
|
// Twilio mulaw 8kHz base64 → PCM16 16kHz base64 (for Gemini input)
|
|
function mulawToGemini (mulawB64) {
|
|
const mulawBuf = Buffer.from(mulawB64, 'base64')
|
|
// Decode mulaw → PCM16 @ 8kHz
|
|
const pcm8k = Buffer.alloc(mulawBuf.length * 2)
|
|
for (let i = 0; i < mulawBuf.length; i++) {
|
|
pcm8k.writeInt16LE(MULAW_TO_LINEAR[mulawBuf[i]], i * 2)
|
|
}
|
|
// Upsample 8kHz → 16kHz (linear interpolation)
|
|
const pcm16k = Buffer.alloc(pcm8k.length * 2)
|
|
for (let i = 0; i < pcm8k.length / 2; i++) {
|
|
const s0 = pcm8k.readInt16LE(i * 2)
|
|
const s1 = (i + 1 < pcm8k.length / 2) ? pcm8k.readInt16LE((i + 1) * 2) : s0
|
|
pcm16k.writeInt16LE(s0, i * 4)
|
|
pcm16k.writeInt16LE(Math.round((s0 + s1) / 2), i * 4 + 2)
|
|
}
|
|
return pcm16k.toString('base64')
|
|
}
|
|
|
|
// Gemini PCM16 24kHz base64 → Twilio mulaw 8kHz base64
|
|
function geminiToMulaw (pcmB64) {
|
|
const pcm24k = Buffer.from(pcmB64, 'base64')
|
|
const samplesIn = pcm24k.length / 2
|
|
// Downsample 24kHz → 8kHz (take every 3rd sample)
|
|
const samplesOut = Math.floor(samplesIn / 3)
|
|
const mulawBuf = Buffer.alloc(samplesOut)
|
|
for (let i = 0; i < samplesOut; i++) {
|
|
const sample = pcm24k.readInt16LE(i * 6)
|
|
mulawBuf[i] = linearToMulaw(sample)
|
|
}
|
|
return mulawBuf.toString('base64')
|
|
}
|
|
|
|
// ── Gemini Live tool definitions (different format than OpenAI) ──
|
|
function buildGeminiTools () {
|
|
const openaiTools = require('./agent-tools.json')
|
|
return [{
|
|
functionDeclarations: openaiTools
|
|
.filter(t => t.function.name !== 'get_chat_link') // voice doesn't need chat link
|
|
.map(t => ({
|
|
name: t.function.name,
|
|
description: t.function.description,
|
|
parameters: t.function.parameters.required?.length
|
|
? { type: 'OBJECT', properties: Object.fromEntries(Object.entries(t.function.parameters.properties).map(([k, v]) => [k, { type: v.type.toUpperCase(), description: v.description || '' }])), required: t.function.parameters.required }
|
|
: { type: 'OBJECT', properties: {} },
|
|
})),
|
|
}]
|
|
}
|
|
|
|
// ── Voice system prompt ──
|
|
const VOICE_SYSTEM_PROMPT = `Tu es l'assistant vocal de Gigafibre, fournisseur Internet fibre optique au Québec.
|
|
|
|
Règles vocales:
|
|
- Parle TOUJOURS en français québécois naturel
|
|
- Sois conversationnel et chaleureux, comme un vrai agent au téléphone
|
|
- Phrases courtes — max 1-2 phrases avant de laisser le client répondre
|
|
- Utilise les outils pour consulter le compte en temps réel
|
|
- Pour les problèmes techniques, vérifie l'état du modem (check_device_status) avant de suggérer quoi que ce soit
|
|
- Signal optique < -25 dBm = problème fibre physique → crée un ticket
|
|
- Modem hors ligne > 10 min → suggère un redémarrage
|
|
- Ne partage JAMAIS d'infos techniques internes (OLT port, network ID, IP de gestion)
|
|
- Si tu ne peux pas résoudre, crée un ticket
|
|
- Quand tu obtiens des données d'un outil, résume en langage simple (pas de jargon technique)`
|
|
|
|
// ── CTI : suivi des agents EN LIGNE (Device Twilio enregistré au navigateur) → on fait sonner leurs softphones ──
|
|
const onlineAgents = new Map() // identity (courriel) → lastSeen ms ; battement toutes les ~30 s côté navigateur
|
|
function recordHeartbeat (identity) { if (identity) onlineAgents.set(String(identity).toLowerCase(), Date.now()) }
|
|
function onlineIdentities () { const now = Date.now(); const out = []; for (const [id, ts] of [...onlineAgents]) { if (now - ts < 75000) out.push(id); else onlineAgents.delete(id) } return out }
|
|
// Agents à FAIRE SONNER pour une file = membres (queue_members.json) avec notif de file ON (queue_notify.json, défaut ON).
|
|
function queueRingAgents (queue) {
|
|
const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).map(e => String(e).toLowerCase())
|
|
const notifyAll = readJsonFile('/app/data/queue_notify.json', {})
|
|
return members.filter(e => { const m = notifyAll[e] || {}; return m[queue] !== false })
|
|
}
|
|
const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''')
|
|
|
|
async function handleHeartbeat (req, res) {
|
|
const body = await parseBody(req)
|
|
recordHeartbeat(body.identity || req.headers['x-authentik-email'] || '')
|
|
return json(res, 200, { ok: true, online: onlineIdentities().length })
|
|
}
|
|
|
|
// Sonne TOUS les softphones en ligne (1er à décrocher gagne) ; chaque Client reçoit le nom du compte + le CallSid parent (pour « Transférer à l'IA »).
|
|
function dialAgentsTwiml (online, { from, account, customerId, callSid }) {
|
|
const clients = online.map(id => ` <Client>
|
|
<Identity>${escAttr(id)}</Identity>
|
|
<Parameter name="account" value="${escAttr(account)}" />
|
|
<Parameter name="from" value="${escAttr(from)}" />
|
|
<Parameter name="customerId" value="${escAttr(customerId)}" />
|
|
<Parameter name="parentCallSid" value="${escAttr(callSid)}" />
|
|
</Client>`).join('\n')
|
|
const action = `${cfg.HUB_PUBLIC_URL}/voice/after-dial?from=${encodeURIComponent(from)}`
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Dial timeout="20" answerOnBridge="true" action="${action}" method="POST">
|
|
${clients}
|
|
</Dial>
|
|
</Response>`
|
|
}
|
|
|
|
// Failover : personne n'a décroché en 20 s → agent IA (client connu) sinon message + raccroche.
|
|
async function handleAfterDial (req, res) {
|
|
const body = await parseBody(req)
|
|
const callSid = body.CallSid || ''
|
|
if ((body.DialCallStatus || '') === 'completed') { res.writeHead(200, { 'Content-Type': 'text/xml' }); return res.end('<Response><Hangup/></Response>') }
|
|
const from = (req.url && req.url.searchParams && req.url.searchParams.get('from')) || body.From || ''
|
|
const customer = from ? await lookupCustomerByPhone(from) : null
|
|
if (customer) {
|
|
const fn = (customer.customer_name || '').split(' ')[0] || 'client'
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' }); return res.end(buildStreamTwiml(callSid, customer, null, fn))
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
res.end('<Response><Say language="fr-CA" voice="Google.fr-CA-Neural2-A">Nos agents ne sont pas disponibles pour le moment. Veuillez nous joindre au 4 5 0, 6 5 5, 3 2 3 5.</Say><Hangup/></Response>')
|
|
}
|
|
|
|
// Transfert MANUEL d'un appel en cours vers l'agent IA : redirige la jambe PSTN (REST Twilio) → /voice/connect-agent.
|
|
async function handleToAI (req, res) {
|
|
const body = await parseBody(req)
|
|
const callSid = body.callSid || ''
|
|
const from = body.from || ''
|
|
if (!callSid) return json(res, 400, { error: 'callSid requis' })
|
|
try {
|
|
const customer = from ? await lookupCustomerByPhone(from) : null
|
|
const url = `${cfg.HUB_PUBLIC_URL}/voice/connect-agent?callSid=${encodeURIComponent(callSid)}&customer=${encodeURIComponent(customer ? customer.name : '')}`
|
|
await require('twilio')(cfg.TWILIO_ACCOUNT_SID, cfg.TWILIO_AUTH_TOKEN).calls(callSid).update({ method: 'POST', url })
|
|
return json(res, 200, { ok: true })
|
|
} catch (e) { return json(res, 500, { error: e.message }) }
|
|
}
|
|
|
|
// ── Inbound call handler: lookup caller, build dynamic IVR ──
|
|
async function handleInboundCall (req, res) {
|
|
const body = await parseBody(req)
|
|
const from = body.From || ''
|
|
const callSid = body.CallSid || ''
|
|
log(`Voice IN: ${from} (CallSid: ${callSid})`)
|
|
|
|
const customer = await lookupCustomerByPhone(from)
|
|
// Screen-pop : prévient les agents (toast coin d'écran + cloche) de l'appel entrant + fiche liée, via SSE 'conversations'. Best-effort, n'affecte pas le flux d'appel.
|
|
const callQueue = cfg.CALL_QUEUE || 'Service à la clientèle'
|
|
try { sse.broadcast('conversations', 'call-incoming', { from, customerId: customer ? customer.name : '', customerName: customer ? (customer.customer_name || customer.name) : '', territory: customer ? (customer.territory || '') : '', callSid, queue: callQueue }) } catch (e) { /* */ }
|
|
// CTI par FILE : on sonne les membres EN LIGNE de la file d'appel (notif ON), 20 s. File sans membre → tous les en-ligne (pas de trou). Sans réponse → IA.
|
|
const online = onlineIdentities()
|
|
const ringSet = queueRingAgents(callQueue)
|
|
const toRing = ringSet.length ? online.filter(id => ringSet.includes(id)) : online
|
|
if (toRing.length) {
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
return res.end(dialAgentsTwiml(toRing, { from, account: customer ? (customer.customer_name || customer.name) : '', customerId: customer ? customer.name : '', callSid }))
|
|
}
|
|
if (!customer) {
|
|
// Unknown caller — polite redirect
|
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say language="fr-CA" voice="Google.fr-CA-Neural2-A">Bonjour, bienvenue chez Gigafibre. Nous n'avons pas trouvé votre numéro dans notre système. Veuillez nous joindre au 4 5 0, 6 5 5, 3 2 3 5.</Say>
|
|
<Hangup/>
|
|
</Response>`
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
return res.end(twiml)
|
|
}
|
|
|
|
// Fetch locations that have active subscriptions (not just any Service Location)
|
|
let locations = []
|
|
try {
|
|
const subFields = JSON.stringify(['service_location', 'item_code', 'custom_description'])
|
|
const subFilters = JSON.stringify({ party_type: 'Customer', party: customer.name, status: 'Active' })
|
|
const r = await erpFetch(`/api/resource/Subscription?filters=${encodeURIComponent(subFilters)}&fields=${encodeURIComponent(subFields)}&limit_page_length=50`)
|
|
const subs = (r.status === 200 && r.data?.data) || []
|
|
// Deduplicate by service_location, keep the service description
|
|
const locMap = new Map()
|
|
for (const s of subs) {
|
|
if (!s.service_location) continue
|
|
if (!locMap.has(s.service_location)) locMap.set(s.service_location, { name: s.service_location, service: s.custom_description || s.item_code })
|
|
}
|
|
// Resolve addresses for each unique location
|
|
for (const [locId, loc] of locMap) {
|
|
try {
|
|
const lr = await erpFetch(`/api/resource/Service Location/${encodeURIComponent(locId)}?fields=["address_line","city"]`)
|
|
if (lr.status === 200 && lr.data?.data) {
|
|
loc.address_line = lr.data.data.address_line || locId
|
|
loc.city = lr.data.data.city || ''
|
|
}
|
|
} catch { loc.address_line = locId; loc.city = '' }
|
|
}
|
|
locations = [...locMap.values()]
|
|
} catch {}
|
|
|
|
const firstName = (customer.customer_name || '').split(' ')[0] || 'client'
|
|
|
|
if (locations.length === 0) {
|
|
// No active locations — go straight to voice agent
|
|
const twiml = buildStreamTwiml(callSid, customer, null, firstName)
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
return res.end(twiml)
|
|
}
|
|
|
|
if (locations.length === 1) {
|
|
// Single location — go straight to voice agent with context
|
|
const twiml = buildStreamTwiml(callSid, customer, locations[0], firstName)
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
return res.end(twiml)
|
|
}
|
|
|
|
// Multiple locations — dynamic IVR
|
|
let gatherOptions = ''
|
|
locations.forEach((loc, i) => {
|
|
const addr = (loc.address_line || '').replace(/&/g, 'et')
|
|
gatherOptions += `Pour le ${addr}, appuyez ${i + 1}. `
|
|
})
|
|
gatherOptions += `Pour autre chose, appuyez 0.`
|
|
|
|
// Store locations in session for the gather callback
|
|
sessions.set(`gather:${callSid}`, { customer, locations })
|
|
|
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Gather numDigits="1" action="${cfg.HUB_PUBLIC_URL}/voice/gather" method="POST" timeout="8">
|
|
<Say language="fr-CA" voice="Google.fr-CA-Neural2-A">Bonjour ${firstName}! ${gatherOptions}</Say>
|
|
</Gather>
|
|
<Say language="fr-CA" voice="Google.fr-CA-Neural2-A">Je n'ai pas reçu de réponse. Je vous transfère à un assistant.</Say>
|
|
<Redirect method="POST">${cfg.HUB_PUBLIC_URL}/voice/connect-agent?callSid=${callSid}&customer=${encodeURIComponent(customer.name)}</Redirect>
|
|
</Response>`
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
res.end(twiml)
|
|
}
|
|
|
|
// ── Gather callback: customer pressed a digit ──
|
|
async function handleGather (req, res) {
|
|
const body = await parseBody(req)
|
|
const digit = body.Digits || '0'
|
|
const callSid = body.CallSid || ''
|
|
|
|
const session = sessions.get(`gather:${callSid}`)
|
|
sessions.delete(`gather:${callSid}`)
|
|
|
|
if (!session) {
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
return res.end('<Response><Say language="fr-CA">Erreur de session. Veuillez rappeler.</Say><Hangup/></Response>')
|
|
}
|
|
|
|
const { customer, locations } = session
|
|
const idx = parseInt(digit, 10)
|
|
const selectedLoc = (idx >= 1 && idx <= locations.length) ? locations[idx - 1] : null
|
|
const firstName = (customer.customer_name || '').split(' ')[0] || 'client'
|
|
|
|
const twiml = buildStreamTwiml(callSid, customer, selectedLoc, firstName)
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
res.end(twiml)
|
|
}
|
|
|
|
// ── Build TwiML that opens a Media Stream to our WebSocket ──
|
|
function buildStreamTwiml (callSid, customer, location, firstName) {
|
|
const wsUrl = cfg.HUB_PUBLIC_URL.replace('https://', 'wss://').replace('http://', 'ws://') + '/voice/ws'
|
|
const meta = JSON.stringify({
|
|
callSid, customerId: customer.name, customerName: customer.customer_name,
|
|
location: location ? { name: location.name, address: location.address_line, city: location.city } : null,
|
|
})
|
|
|
|
const locContext = location
|
|
? ` pour votre adresse au ${(location.address_line || '').replace(/&/g, 'et')}`
|
|
: ''
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say language="fr-CA" voice="Google.fr-CA-Neural2-A">Parfait ${firstName}, je vous mets en ligne avec notre assistant${locContext}. Un moment.</Say>
|
|
<Connect>
|
|
<Stream url="${wsUrl}" statusCallbackMethod="POST">
|
|
<Parameter name="meta" value="${Buffer.from(meta).toString('base64')}" />
|
|
</Stream>
|
|
</Connect>
|
|
</Response>`
|
|
}
|
|
|
|
// ── Connect to agent directly (no location selection) ──
|
|
async function handleConnectAgent (req, res) {
|
|
const body = await parseBody(req)
|
|
const callSid = body.CallSid || req.url?.searchParams?.get('callSid') || ''
|
|
const customerName = req.url?.searchParams?.get('customer') || ''
|
|
|
|
let customer = null
|
|
if (customerName) {
|
|
try {
|
|
const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerName)}`)
|
|
if (r.status === 200) customer = r.data.data
|
|
} catch {}
|
|
}
|
|
|
|
const firstName = customer ? (customer.customer_name || '').split(' ')[0] : ''
|
|
const twiml = buildStreamTwiml(callSid, customer || { name: customerName, customer_name: customerName }, null, firstName || 'client')
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' })
|
|
res.end(twiml)
|
|
}
|
|
|
|
// ── WebSocket handler: Twilio Media Stream ↔ Gemini Live ──
|
|
function handleMediaStream (ws, req) {
|
|
let geminiWs = null
|
|
let streamSid = null
|
|
let meta = null
|
|
let callSid = null
|
|
|
|
ws.on('message', async (data) => {
|
|
let msg
|
|
try { msg = JSON.parse(data) } catch { return }
|
|
|
|
switch (msg.event) {
|
|
case 'connected':
|
|
log('Twilio Media Stream connected')
|
|
break
|
|
|
|
case 'start':
|
|
streamSid = msg.start?.streamSid
|
|
callSid = msg.start?.callSid
|
|
// Decode metadata passed from TwiML
|
|
const metaB64 = msg.start?.customParameters?.meta
|
|
if (metaB64) {
|
|
try { meta = JSON.parse(Buffer.from(metaB64, 'base64').toString()) } catch {}
|
|
}
|
|
log(`Voice session started: ${callSid} customer=${meta?.customerId}`)
|
|
|
|
// Open Gemini Live session
|
|
geminiWs = await openGeminiSession(ws, streamSid, meta)
|
|
if (geminiWs) {
|
|
sessions.set(callSid, { ws, geminiWs, streamSid, meta, startedAt: Date.now() })
|
|
}
|
|
break
|
|
|
|
case 'media':
|
|
// Forward audio to Gemini (convert mulaw → PCM16)
|
|
if (geminiWs?.readyState === WebSocket.OPEN && msg.media?.payload) {
|
|
try {
|
|
const pcmB64 = mulawToGemini(msg.media.payload)
|
|
geminiWs.send(JSON.stringify({
|
|
realtimeInput: { audio: { data: pcmB64, mimeType: 'audio/pcm;rate=16000' } },
|
|
}))
|
|
} catch {}
|
|
}
|
|
break
|
|
|
|
case 'stop':
|
|
log(`Voice session ended: ${callSid}`)
|
|
if (geminiWs) { try { geminiWs.close() } catch {} }
|
|
if (callSid) {
|
|
const session = sessions.get(callSid)
|
|
if (session) {
|
|
const duration = Math.floor((Date.now() - session.startedAt) / 1000)
|
|
logVoiceSession(meta, duration)
|
|
sessions.delete(callSid)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
})
|
|
|
|
ws.on('close', () => {
|
|
if (geminiWs) try { geminiWs.close() } catch {}
|
|
})
|
|
}
|
|
|
|
// ── Open Gemini Live WebSocket session ──
|
|
async function openGeminiSession (twilioWs, streamSid, meta) {
|
|
if (!cfg.AI_API_KEY) {
|
|
log('Voice agent: no AI_API_KEY configured')
|
|
return null
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const url = `${GEMINI_WS}?key=${cfg.AI_API_KEY}`
|
|
const ws = new WebSocket(url)
|
|
let setupDone = false
|
|
|
|
const timeout = setTimeout(() => {
|
|
if (!setupDone) { log('Gemini Live: setup timeout'); ws.close(); resolve(null) }
|
|
}, 10000)
|
|
|
|
ws.on('open', () => {
|
|
// Build context from customer data
|
|
const locContext = meta?.location
|
|
? `\nAdresse sélectionnée: ${meta.location.address}, ${meta.location.city} (${meta.location.name})`
|
|
: ''
|
|
|
|
ws.send(JSON.stringify({
|
|
setup: {
|
|
model: LIVE_MODEL,
|
|
generationConfig: {
|
|
responseModalities: ['AUDIO'],
|
|
speechConfig: {
|
|
voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Aoede' } },
|
|
},
|
|
},
|
|
systemInstruction: {
|
|
parts: [{ text: `${VOICE_SYSTEM_PROMPT}\n\nContexte: Client ${meta?.customerName || 'inconnu'} (${meta?.customerId || '?'})${locContext}` }],
|
|
},
|
|
tools: buildGeminiTools(),
|
|
realtimeInputConfig: {
|
|
automaticActivityDetection: {
|
|
disabled: false,
|
|
startOfSpeechSensitivity: 'HIGH',
|
|
endOfSpeechSensitivity: 'MEDIUM',
|
|
silenceDurationMs: 1200,
|
|
},
|
|
activityHandling: 'START_OF_ACTIVITY_INTERRUPTS',
|
|
},
|
|
inputAudioTranscription: {},
|
|
outputAudioTranscription: {},
|
|
},
|
|
}))
|
|
})
|
|
|
|
ws.on('message', async (data) => {
|
|
let msg
|
|
try { msg = JSON.parse(data.toString()) } catch { return }
|
|
|
|
// Setup confirmation
|
|
if (msg.setupComplete) {
|
|
setupDone = true
|
|
clearTimeout(timeout)
|
|
log(`Gemini Live session ready (model: ${LIVE_MODEL})`)
|
|
resolve(ws)
|
|
return
|
|
}
|
|
|
|
// Audio response from Gemini → convert and send to Twilio
|
|
if (msg.serverContent?.modelTurn?.parts) {
|
|
for (const part of msg.serverContent.modelTurn.parts) {
|
|
if (part.inlineData?.data) {
|
|
try {
|
|
const mulawB64 = geminiToMulaw(part.inlineData.data)
|
|
if (twilioWs.readyState === WebSocket.OPEN) {
|
|
twilioWs.send(JSON.stringify({
|
|
event: 'media',
|
|
streamSid,
|
|
media: { payload: mulawB64 },
|
|
}))
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Transcription (for logging)
|
|
if (msg.serverContent?.outputTranscription?.text) {
|
|
log(`Voice AI: ${msg.serverContent.outputTranscription.text.substring(0, 100)}`)
|
|
}
|
|
if (msg.serverContent?.inputTranscription?.text) {
|
|
log(`Voice Customer: ${msg.serverContent.inputTranscription.text.substring(0, 100)}`)
|
|
}
|
|
|
|
// Tool call from Gemini
|
|
if (msg.toolCall?.functionCalls) {
|
|
const responses = []
|
|
for (const fc of msg.toolCall.functionCalls) {
|
|
log(`Voice tool call: ${fc.name}(${JSON.stringify(fc.args)})`)
|
|
const result = await execTool(fc.name, fc.args || {})
|
|
log(`Voice tool result: ${fc.name} →`, JSON.stringify(result).substring(0, 200))
|
|
responses.push({ id: fc.id, name: fc.name, response: { result } })
|
|
}
|
|
ws.send(JSON.stringify({ toolResponse: { functionResponses: responses } }))
|
|
}
|
|
})
|
|
|
|
ws.on('error', (e) => {
|
|
log('Gemini Live WS error:', e.message)
|
|
if (!setupDone) { clearTimeout(timeout); resolve(null) }
|
|
})
|
|
|
|
ws.on('close', () => {
|
|
log('Gemini Live session closed')
|
|
})
|
|
})
|
|
}
|
|
|
|
// ── Log voice session to ERPNext ──
|
|
async function logVoiceSession (meta, duration) {
|
|
if (!meta?.customerId) return
|
|
try {
|
|
const dMin = Math.floor(duration / 60), dSec = duration % 60
|
|
const durStr = `${dMin}m${dSec.toString().padStart(2, '0')}s`
|
|
await createCommunication({
|
|
communication_type: 'Communication', communication_medium: 'Phone',
|
|
sent_or_received: 'Received', sender: 'voice-ai@gigafibre.ca',
|
|
sender_full_name: 'Gigafibre Voice AI', phone_no: meta.phone || '',
|
|
content: `Appel vocal IA — Client: ${meta.customerName}, Durée: ${durStr}${meta.location ? ', Adresse: ' + meta.location.address : ''}`,
|
|
subject: `Appel vocal IA: ${meta.customerName}`,
|
|
reference_doctype: 'Customer', reference_name: meta.customerId, status: 'Linked',
|
|
})
|
|
log(`Voice session logged: ${meta.customerId} (${durStr})`)
|
|
} catch (e) { log('Voice session log error:', e.message) }
|
|
}
|
|
|
|
module.exports = { handleInboundCall, handleGather, handleConnectAgent, handleMediaStream, handleAfterDial, handleToAI, handleHeartbeat }
|