'use strict' /** * Receptor de Webhooks — motor NewWhats → satélite Alemão * * Rota: POST /api/webhook/newwhats * Origem: motor (newwhats.local:8008) via HTTP direto * * Validação: * Header x-nw-signature: sha256= * HMAC-SHA256(webhook_secret, rawBody) * * Eventos tratados: * message.new — nova mensagem recebida/enviada * session.status — instância conectou / desconectou * * Auto-resposta (Frente 2 v2.2): * Modo controlado por plugin_config auto_reply_mode: * off → só loga (comportamento anterior) * automatico → chama brain do motor e envia reply * * Regras de segurança: * - fromMe=true → nunca responde * - role=notificacoes → nunca responde * - cooldown 15s por chat_id → evita flood (era 45s, reduzido pois bloqueava 2ª msg em conversa ativa) * - max 8 replies/conversa em 1h → anti-loop * - só responde msg tipo TEXT */ const { createHmac, timingSafeEqual } = require('crypto') const { Router } = require('express') const { queryOne, query, execute } = require('../../database/postgres') const SECRET_KEY = 'webhook_secret' const MOTOR_URL_KEY = 'newwhats_url' const INTEG_KEY_KEY = 'integration_key' const AUTO_MODE_KEY = 'auto_reply_mode' // off | automatico const COOLDOWN_MS = 15_000 // 15s entre replies no mesmo chat const MAX_REPLIES_1H = 8 // máx respostas automáticas por chat por hora // ── Config helpers ──────────────────────────────────────────────────────────── // Cache em memória: evita query ao banco em cada mensagem recebida. // Config muda raramente (admin altera via painel) — TTL de 60s é seguro. let _cfgCache = null let _cfgExpiry = 0 async function getPluginConfig() { if (_cfgCache && Date.now() < _cfgExpiry) return _cfgCache const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`) _cfgCache = {} for (const r of rows) _cfgCache[r.key] = r.value _cfgExpiry = Date.now() + 60_000 return _cfgCache } // Cache de role por instance_id — HTTP ao motor em cada msg custa 200-500ms. // TTL de 5min: role muda raramente (admin altera via painel da secretária). const _numberRoleCache = new Map() const NUMBER_ROLE_TTL = 5 * 60_000 async function getNumberRole(motorUrl, integKey, instanceId) { const cached = _numberRoleCache.get(instanceId) if (cached && Date.now() - cached.ts < NUMBER_ROLE_TTL) return cached try { const res = await fetch(`${motorUrl}/api/ext/v1/secretaria/numbers`, { headers: { 'x-nw-key': integKey ?? '' }, }) if (!res.ok) return null const nums = await res.json() const info = Array.isArray(nums) ? nums.find(n => n.instance_id === instanceId) : null const entry = { role: info?.role ?? null, active: info?.active ?? true, ts: Date.now() } _numberRoleCache.set(instanceId, entry) return entry } catch { return null } } // ── Assinatura HMAC ─────────────────────────────────────────────────────────── async function getSecret() { const row = await queryOne( "SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = $1", [SECRET_KEY], ) return row?.value ?? null } function verifySignature(secret, rawBody, sigHeader) { if (!sigHeader?.startsWith('sha256=')) return false const expected = Buffer.from( 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'), ) const received = Buffer.from(sigHeader) if (expected.length !== received.length) return false return timingSafeEqual(expected, received) } // ── Roteador principal ──────────────────────────────────────────────────────── function createWebhookReceiver() { const router = Router() router.post('/api/webhook/newwhats', async (req, res) => { try { const sig = req.headers['x-nw-signature'] const secret = await getSecret() if (!secret) { console.warn('[nw-webhook] webhook_secret não configurado — assinatura ignorada') } else { const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body)) if (!verifySignature(secret, rawBody, sig)) { console.warn('[nw-webhook] Assinatura inválida — rejeitado') return res.status(401).json({ error: 'Assinatura inválida' }) } } const { event, data } = req.body ?? {} if (!event || !data) return res.status(400).json({ error: 'Payload inválido' }) if (event === 'message.new') { await handleMessageNew(data) } else if (event === 'session.status') { await handleSessionStatus(data) } res.json({ ok: true }) } catch (err) { console.error('[nw-webhook] Erro interno:', err.message) res.status(500).json({ error: 'Erro interno' }) } }) return router } // ── Handler: message.new ────────────────────────────────────────────────────── async function handleMessageNew(data) { const msg = data?.message if (!msg) return // 1. Loga sempre — captura id para poder atualizar payload após extração de mídia const logRow = await queryOne( `INSERT INTO nw_event_logs (instance_id, event, direction, chat_id, from_me, msg_type, payload) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, [ data.instanceId ?? '', 'message.new', msg.fromMe ? 'out' : 'in', data.chatId ?? null, msg.fromMe ?? false, msg.type ?? null, JSON.stringify({ body: msg.body, hasMedia: msg.hasMedia, author: msg.author, pushName: msg.pushName }), ], ) const logId = logRow?.id ?? null // 2. Nunca responder mensagem enviada pelo próprio número. // Se havia handoff ativo, o operador respondeu — libera a IA imediatamente. if (msg.fromMe) { try { const { sseBroadcast } = require('../../services/sse') const chatId = data.chatId ?? '' const updated = await execute( `UPDATE nw_handoff SET human_until=NULL, updated_at=NOW() WHERE chat_id=$1 AND human_until > NOW()`, [chatId] ) if (updated?.rowCount > 0) { console.log(`[nw-webhook] handoff limpo após resposta do operador — ${chatId}`) sseBroadcast('handoff.expired', { chatId }) } } catch { /* silencioso */ } return } // 2b. Captura avaliação de satisfação (1–5) se houver pesquisa pendente const chatId = data.chatId ?? '' const body = (msg.body ?? '').trim() if (/^[1-5]$/.test(body)) { try { const { queryOne: qOne, execute: dbExec } = require('../../database/postgres') const pending = await qOne( `SELECT id FROM protocol_ratings WHERE chat_id=$1 AND status='pending' ORDER BY sent_at DESC LIMIT 1`, [chatId] ) if (pending) { await dbExec( `UPDATE protocol_ratings SET rating=$1, status='rated', rated_at=NOW() WHERE id=$2`, [parseInt(body), pending.id] ) console.log(`[nw-webhook] ⭐ Avaliação ${body}/5 registrada para ${chatId}`) return // não processa como auto-reply } } catch { /* silencioso */ } } // 3. Lê config do plugin const cfg = await getPluginConfig() const mode = cfg[AUTO_MODE_KEY] ?? 'off' if (mode === 'off') return // 4. Chats de grupos ignorados (@g.us) if (chatId.endsWith('@g.us')) return // 5. Tipo de mensagem: TEXT, AUDIO (PTT), IMAGE, DOCUMENT (PDF), VIDEO e // CONTACT/VCARD são processados. Áudio é transcrito por Whisper/Gemini; // imagem é descrita/classificada; PDF tem texto extraído; vídeo é // descrito (cena+áudio) por Gemini nativo; vCard é parseado localmente. // LOCATION (GPS): TODO — resolver coordenadas para endereço via Maps API. // STICKER e outros: ignorados. const msgType = (msg.type ?? '').toUpperCase() let effectiveBody = body if (msgType === 'AUDIO' || msgType === 'PTT') { if (!msg.id) { console.warn('[nw-auto] áudio sem msg.id, ignorando') return } try { const { transcribeAudio } = require('./media-transcribe') const transcript = await transcribeAudio({ motorUrl: cfg[MOTOR_URL_KEY], integKey: cfg[INTEG_KEY_KEY], messageId: msg.id, }) if (!transcript) { console.log(`[nw-auto] áudio inaudível ou falha de transcrição em ${chatId}`) return } console.log(`[nw-auto] 🎙️ transcrição (${transcript.length} chars): "${transcript.slice(0, 80)}…"`) effectiveBody = transcript } catch (e) { console.warn('[nw-auto] transcribeAudio erro:', e.message) return } } else if (msgType === 'IMAGE') { if (!msg.id) { console.warn('[nw-auto] imagem sem msg.id, ignorando') return } try { const { describeImage } = require('./media-transcribe') const description = await describeImage({ motorUrl: cfg[MOTOR_URL_KEY], integKey: cfg[INTEG_KEY_KEY], messageId: msg.id, captionFromUser: body || null, }) if (!description) { console.log(`[nw-auto] imagem sem descrição obtida em ${chatId}`) return } console.log(`[nw-auto] 🖼️ ${description.slice(0, 100)}`) effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description } catch (e) { console.warn('[nw-auto] describeImage erro:', e.message) return } } else if (msgType === 'DOCUMENT') { if (!msg.id) { console.warn('[nw-auto] documento sem msg.id, ignorando') return } try { const { extractPdfText } = require('./media-transcribe') const pdfText = await extractPdfText({ motorUrl: cfg[MOTOR_URL_KEY], integKey: cfg[INTEG_KEY_KEY], messageId: msg.id, captionFromUser: body || null, }) if (!pdfText) { console.log(`[nw-auto] documento não processável (tipo não suportado ou muito grande) em ${chatId}`) return } console.log(`[nw-auto] 📄 PDF extraído (${pdfText.length} chars) em ${chatId}`) effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText // Persiste texto extraído no log para que pings de follow-up ("???") // possam recuperar o conteúdo do PDF como "pergunta pendente". if (logId) { execute( `UPDATE nw_event_logs SET payload = $1 WHERE id = $2`, [JSON.stringify({ body: pdfText, hasMedia: true, originalCaption: body || null, author: msg.author, pushName: msg.pushName }), logId], ).catch(e => console.warn('[nw-auto] falha ao persistir texto PDF no log:', e.message)) } } catch (e) { console.warn('[nw-auto] extractPdfText erro:', e.message) return } } else if (msgType === 'VIDEO') { if (!msg.id) { console.warn('[nw-auto] vídeo sem msg.id, ignorando') return } try { const { processVideo } = require('./media-transcribe') const description = await processVideo({ motorUrl: cfg[MOTOR_URL_KEY], integKey: cfg[INTEG_KEY_KEY], messageId: msg.id, captionFromUser: body || null, }) if (!description) { console.log(`[nw-auto] vídeo não processável (muito grande ou erro) em ${chatId}`) return } console.log(`[nw-auto] 🎬 ${description.slice(0, 100)}`) effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description } catch (e) { console.warn('[nw-auto] processVideo erro:', e.message) return } } else if (msgType === 'CONTACT' || msgType === 'VCARD' || msgType === 'CONTACTS_ARRAY') { try { const { extractVCard } = require('./media-transcribe') const card = extractVCard({ rawVcard: msg.vcard, msgBody: msg.body }) if (!card) { console.log(`[nw-auto] vCard sem dados úteis em ${chatId}`) return } console.log(`[nw-auto] 👤 ${card.slice(0, 120)}`) effectiveBody = card } catch (e) { console.warn('[nw-auto] extractVCard erro:', e.message) return } } else if (msgType === 'LOCATION') { // TODO: implementar reverse geocoding (Maps API) para resolver // msg.location.latitude/longitude → endereço legível e injetar em effectiveBody. // Útil para confirmar endereço de entrega quando cliente compartilha localização. console.log(`[nw-auto] LOCATION recebida em ${chatId} — handler ainda não implementado`) return } else if (msgType !== 'TEXT' && msgType !== '') { return } if (!effectiveBody) return // 5c. Detecção de follow-up/ping ("?????", "verificou??", "alô?", "tá aí?") // Quando o cliente está cobrando resposta: // - Bypassa cooldown (a inação é o problema dele, não excesso de respostas) // - Recupera a última pergunta sem resposta no chat // - Enriquece o body com contexto antes de mandar ao LLM let isFollowUp = false let pendingQuestion = null try { const { isFollowUpPing, findPendingQuestion, buildEnrichedBody } = require('./follow-up-detector') if (isFollowUpPing(effectiveBody)) { isFollowUp = true pendingQuestion = await findPendingQuestion(chatId, 1440) const enriched = buildEnrichedBody(effectiveBody, pendingQuestion) console.log(`[nw-auto] 🔔 ping detectado em ${chatId} — pergunta pendente: ${pendingQuestion ? `"${pendingQuestion.body.slice(0,60)}…" (${pendingQuestion.ageMinutes}min)` : 'nenhuma'}`) effectiveBody = enriched // Se handoff humano está ativo, alertar o painel admin via SSE — operador // precisa ver que o cliente está cobrando. IA segue pausada (não responde). try { const handoffRow = await queryOne( `SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`, [chatId] ) if (handoffRow?.human_until && new Date(handoffRow.human_until) > new Date()) { const { sseBroadcast } = require('../../services/sse') sseBroadcast('handoff.followup_ping', { chatId, instanceId: data.instanceId ?? '', pingBody: body, pendingQuestion: pendingQuestion?.body ?? null, ageMinutes: pendingQuestion?.ageMinutes ?? null, at: new Date().toISOString(), }) console.log(`[nw-auto] 📣 handoff ativo + ping → SSE handoff.followup_ping emitido`) } } catch (e) { console.warn('[nw-auto] handoff/SSE check falhou:', e.message) } } } catch (e) { console.warn('[nw-auto] follow-up-detector erro:', e.message) } // 6. Verifica role do número — notificacoes nunca auto-responde (com cache 5min) try { const numInfo = await getNumberRole(cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY], data.instanceId ?? '') if (numInfo?.role === 'notificacoes') return if (numInfo && numInfo.active === false) return } catch { /* se falhar a leitura, deixa passar — melhor responder do que parar */ } // 7. Cooldown: evita responder o mesmo chat muito rápido // EXCEÇÕES: // - Pings de follow-up bypassam — cliente cobrando precisa de feedback. // - Última resposta foi do smart-router (status='sent_local') — sem custo // de LLM, então não há motivo de cooldown. Cliente fazendo follow-up // imediato a uma resposta determinística não pode ser bloqueado. // Limite anti-loop (step 8) ainda se aplica em todos os casos. const lastReply = await queryOne( `SELECT created_at, status FROM nw_auto_replies WHERE chat_id = $1 ORDER BY created_at DESC LIMIT 1`, [chatId], ) if (lastReply && !isFollowUp && lastReply.status === 'sent') { const elapsedMs = Date.now() - new Date(lastReply.created_at).getTime() if (elapsedMs < COOLDOWN_MS) { console.log(`[nw-auto] cooldown ativo para ${chatId} (${Math.round(elapsedMs/1000)}s < ${COOLDOWN_MS/1000}s)`) return } } // 8. Anti-loop: máx MAX_REPLIES_1H por chat por hora const countRow = await queryOne( `SELECT COUNT(*) AS cnt FROM nw_auto_replies WHERE chat_id = $1 AND created_at > NOW() - INTERVAL '1 hour'`, [chatId], ) if (parseInt(countRow?.cnt ?? 0, 10) >= MAX_REPLIES_1H) { console.log(`[nw-auto] limite de ${MAX_REPLIES_1H} respostas/hora atingido para ${chatId}`) return } // 9a. Smart Router — tenta responder localmente sem LLM (cotação, FAQ, horário etc) // Usa effectiveBody (texto original ou transcrição/descrição de mídia) // Em caso de follow-up/ping, pula direto ao LLM — o body foi enriquecido com // contexto da pergunta original e o smart-router não tem como atender. setImmediate(async () => { try { if (isFollowUp) { autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from) return } const { tryLocalReply, sendLocalReply } = require('./smart-router') const local = await tryLocalReply(effectiveBody, data.from, 1) if (local) { const ok = await sendLocalReply( cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY], data.instanceId, chatId, local.reply, local.intent, effectiveBody ) if (ok) return // Respondido localmente, não chama LLM } // Se smart-router não acertou ou envio falhou, segue ao LLM autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from) } catch (e) { console.warn('[smart-router] erro, caindo no LLM:', e.message) autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from) } }) } // ── Auto-reply: chama brain e envia resposta ────────────────────────────────── async function autoReply(cfg, instanceId, chatId, userMsg, contactName, fromPhone) { const motorUrl = cfg[MOTOR_URL_KEY] const integKey = cfg[INTEG_KEY_KEY] if (!motorUrl || !integKey) { console.warn('[nw-auto] motor URL ou integration_key não configurados') return } try { // 1. Verifica handoff local — se humano está respondendo, não chamar IA try { const { queryOne: qOne } = require('../../database/postgres') const hRow = await qOne( `SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`, [chatId] ) if (hRow?.human_until && new Date(hRow.human_until) > new Date()) { console.log(`[nw-auto] handoff=humano (local) para ${chatId} — IA pausada`) return } } catch { /* falha silenciosa */ } // 2. Monta contexto local do projeto let context = undefined let systemExtra = undefined let intentTags = [] try { const { buildContext } = require('./context-builder') const phone = (fromPhone ?? '').replace(/\D/g, '').replace(/^55/, '') const ctx = await buildContext(phone, 1, userMsg, chatId) if (ctx) { context = ctx systemExtra = ctx._systemExtra intentTags = ctx.intencoes_detectadas ?? [] delete context._systemExtra } } catch (e) { console.warn('[nw-auto] context-builder falhou:', e.message) } // 2a-bis. Intent automático por tipo de mídia — adiciona tags ao brain // (mais barato e preciso do que esperar o LLM detectar sozinho). try { const { detectMediaIntent } = require('./media-transcribe') const mediaTags = detectMediaIntent(userMsg) if (mediaTags.length > 0) { intentTags = [...new Set([...intentTags, ...mediaTags])] console.log(`[nw-auto] 🏷️ intent por mídia: [${mediaTags.join(',')}]`) } } catch { /* silencioso */ } // 2a. Persona — define tom de voz/escopo de atendimento por tenant. // Vai ANTES da BASE DE CONHECIMENTO para o LLM ler primeiro o "como falar". try { const { resolvePersona } = require('./personas') const persona = await resolvePersona(1) if (persona) { const personaBlock = `=== PERSONA DE ATENDIMENTO (${persona.label}) ===\n${persona.prompt}` systemExtra = [personaBlock, systemExtra].filter(Boolean).join('\n\n') } } catch (e) { console.warn('[nw-auto] persona injection falhou:', e.message) } // 2b. Injeta brain — filtrado por intent (core sempre + tags relevantes) try { const { query: dbq, queryOne: qOne } = require('../../database/postgres') const chatSector = await qOne( `SELECT sector_id FROM nw_chat_sectors WHERE chat_id=$1 LIMIT 1`, [chatId] ) const sectorClause = chatSector?.sector_id ? `AND sbn.sector_id = ${Number(chatSector.sector_id)}` : '' const tagsToLoad = ['core', ...intentTags] const brainRows = await dbq( `SELECT sbn.type, sbn.content, s.name AS sector_name, sbn.sort_order FROM sector_brain_nodes sbn JOIN sectors s ON s.id = sbn.sector_id WHERE sbn.active = TRUE ${sectorClause} AND sbn.tags && $1::text[] ORDER BY s.sort_order, sbn.sort_order LIMIT 20`, [tagsToLoad] ) if (brainRows.length > 0) { const brainText = brainRows.map(r => `[${r.type.toUpperCase()}${chatSector?.sector_id ? '' : ` — ${r.sector_name}`}]\n${r.content}` ).join('\n\n') const brainBlock = `=== BASE DE CONHECIMENTO ===\n${brainText}` systemExtra = [systemExtra, brainBlock].filter(Boolean).join('\n\n') console.log(`[nw-auto] brain: ${brainRows.length} nós (~${brainText.length} chars) tags=[${tagsToLoad.join(',')}]`) } } catch (e) { console.warn('[nw-auto] brain injection falhou:', e.message) } // 3. Chama /api/ext/v1/secretaria/ask (cria/retoma conversa + brain.chat()) const askRes = await fetch(`${motorUrl}/api/ext/v1/secretaria/ask`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-key': integKey, }, body: JSON.stringify({ chatId, message: userMsg, contactName: contactName ?? chatId, context, systemExtra, tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'], }), }) if (!askRes.ok) { const txt = await askRes.text() console.error(`[nw-auto] brain ask failed ${askRes.status}:`, txt) await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed') return } const askJson = await askRes.json() const { reply, conversationId, humanApiRequest, sectorId: detectedSectorId } = askJson // 3a. Sincroniza setor identificado pela IA → banco local if (detectedSectorId) { try { const { execute: dbExec } = require('../../database/postgres') await dbExec( `INSERT INTO nw_chat_sectors (chat_id, tenant_id, sector_id, updated_at) VALUES ($1,1,$2,NOW()) ON CONFLICT (chat_id, tenant_id) DO UPDATE SET sector_id=$2, updated_at=NOW()`, [chatId, detectedSectorId] ) } catch { /* silencioso */ } } // 3b. Human API — IA precisa de resposta de especialista humano if (humanApiRequest) { try { const localUrl = `http://127.0.0.1:${process.env.PORT || 4001}` await fetch(`${localUrl}/api/human-api/request`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-internal-secret': process.env.INTERNAL_WEBHOOK_SECRET || '' }, body: JSON.stringify({ chatId, conversationId, instanceId, question: humanApiRequest.question || humanApiRequest, sectorName: humanApiRequest.sectorName || null, }), }) } catch (e) { console.warn('[nw-auto] human_api forward falhou:', e.message) } } if (!reply?.trim()) return // 2. Envia resposta via motor const sendRes = await fetch(`${motorUrl}/api/ext/v1/inbox/${encodeURIComponent(chatId)}/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-key': integKey, }, body: JSON.stringify({ text: reply }), }) if (!sendRes.ok) { const txt = await sendRes.text() console.error(`[nw-auto] send failed ${sendRes.status}:`, txt) await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'failed') return } // 3. Loga sucesso + telemetria await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'sent') const ctxChars = (systemExtra?.length || 0) + (context ? JSON.stringify(context).length : 0) const saved = Math.max(0, 5000 - ctxChars) // baseline de 5000 chars await execute( `INSERT INTO nw_router_metrics (chat_id, handler, intent, user_msg, reply_chars, ctx_chars, saved_chars) VALUES ($1, 'llm', $2, $3, $4, $5, $6)`, [chatId, intentTags.join(',') || null, userMsg.slice(0, 200), reply.length, ctxChars, saved] ).catch(() => {}) console.log(`[nw-auto] ✅ ${chatId} → "${reply.slice(0, 60)}…" (ctx=${ctxChars}c, saved=${saved}c)`) } catch (err) { console.error('[nw-auto] erro inesperado:', err.message) await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed').catch(() => {}) } } async function logAutoReply(instanceId, chatId, convId, userMsg, reply, status) { await execute( `INSERT INTO nw_auto_replies (instance_id, chat_id, conv_id, user_msg, reply, status) VALUES ($1, $2, $3, $4, $5, $6)`, [instanceId, chatId, convId ?? null, userMsg, reply ?? null, status], ) } // ── Handler: session.status ─────────────────────────────────────────────────── async function handleSessionStatus(data) { await execute( `INSERT INTO nw_event_logs (instance_id, event, direction, payload) VALUES ($1, $2, 'in', $3)`, [ data.instanceId ?? '', 'session.status', JSON.stringify({ status: data.status }), ], ) console.log(`[nw-webhook] session.status → ${data.instanceId}: ${data.status}`) } module.exports = { createWebhookReceiver }