// ==UserScript== // @name Alerta Tiledesk - Proxxima // @namespace https://tiledesk.proxxima.net/ // @version 1.8.8 // @description Avisa o atendente quando cai um atendimento novo, quando chega mensagem nova numa conversa ja aberta e quando um colega escreve no bate-papo direto; conserta a rolagem do historico; pausas (almoco e 10 minutos); imagem/PDF chegando no WhatsApp do cliente; gravacao de audio; respostas predefinidas no bate-papo direto; agente so ve as proprias conversas no painel // @author Proxxima // @updateURL https://alerta-tiledesk.pages.dev/alerta-tiledesk.user.js // @downloadURL https://alerta-tiledesk.pages.dev/alerta-tiledesk.user.js // @match https://tiledesk.proxxima.net/chat/* // @match https://tiledesk.proxxima.net/dashboard/* // @run-at document-start // @grant GM_xmlhttpRequest // @connect script.google.com // @require https://alerta-tiledesk.pages.dev/lame.min.js // ==/UserScript== // ------------------------------------------------------------------------ // Anexos (imagem/PDF) chegando no WhatsApp - versao 1.7.0 // // O chat-config.json do servidor traz "baseImageUrl": "/api/" (relativo). O chat // monta o link do anexo a partir dele, entao a mensagem sai com // src = "/api/images?path=..." e downloadURL = "/api/files/download?path=...". // O conector do WhatsApp repassa esse link para a Meta tal como esta, a Meta nao // consegue baixar um endereco relativo e o cliente nunca recebe a imagem/PDF - // embora no chat do atendente ela apareca como enviada. // // Aqui o arquivo de configuracao e corrigido no caminho, antes do chat ler: // baseImageUrl passa a ser absoluto (https://tiledesk.proxxima.net/api/). // Precisa rodar em document-start, porque o chat le a configuracao uma unica vez, // logo na abertura. O conserto definitivo e o mesmo valor no chat-config.json do // servidor; quando isso for feito, este bloco simplesmente nao muda nada. // ------------------------------------------------------------------------ (function () { 'use strict'; if (location.pathname.indexOf('/chat/') !== 0) return; var W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; var ALVO = /\/chat-config\.json$/; function ehConfig(url) { try { return ALVO.test(new URL(String(url), W.location.href).pathname); } catch (e) { return false; } } function absoluto(base) { if (typeof base !== 'string' || base.charAt(0) !== '/' || base.charAt(1) === '/') return base; return W.location.origin + base; } function corrigirObjeto(j) { if (j && typeof j === 'object' && typeof j.baseImageUrl === 'string') { var novo = absoluto(j.baseImageUrl); if (novo !== j.baseImageUrl) { console.log('[alerta-tiledesk] baseImageUrl ' + j.baseImageUrl + ' -> ' + novo); j.baseImageUrl = novo; } } return j; } function corrigirTexto(txt) { if (typeof txt !== 'string' || txt.indexOf('baseImageUrl') < 0) return txt; try { return JSON.stringify(corrigirObjeto(JSON.parse(txt))); } catch (e) { return txt; } } // XMLHttpRequest - e por aqui que o Angular (HttpClient) busca o arquivo try { var XP = W.XMLHttpRequest.prototype; var abrir = XP.open; XP.open = function (metodo, url) { this.__tdConfig = ehConfig(url); return abrir.apply(this, arguments); }; var dTexto = Object.getOwnPropertyDescriptor(XP, 'responseText'); var dResp = Object.getOwnPropertyDescriptor(XP, 'response'); if (dTexto && dTexto.get) { Object.defineProperty(XP, 'responseText', { configurable: true, enumerable: dTexto.enumerable, get: function () { var v = dTexto.get.call(this); return (this.__tdConfig && this.readyState === 4) ? corrigirTexto(v) : v; } }); } if (dResp && dResp.get) { Object.defineProperty(XP, 'response', { configurable: true, enumerable: dResp.enumerable, get: function () { var v = dResp.get.call(this); if (!this.__tdConfig || this.readyState !== 4) return v; return (typeof v === 'string') ? corrigirTexto(v) : corrigirObjeto(v); } }); } } catch (e) { console.log('[alerta-tiledesk] falha ao preparar XHR', e); } // fetch - por garantia, caso uma versao futura do chat troque o metodo try { var buscar = W.fetch; if (buscar) { W.fetch = function (entrada) { var url = (entrada && entrada.url) ? entrada.url : entrada; var p = buscar.apply(W, arguments); if (!ehConfig(url)) return p; return p.then(function (r) { if (!r || !r.ok) return r; return r.clone().text().then(function (t) { var novo = corrigirTexto(t); if (novo === t) return r; return new Response(novo, { status: r.status, statusText: r.statusText, headers: r.headers }); }).catch(function () { return r; }); }); }; } } catch (e) { console.log('[alerta-tiledesk] falha ao preparar fetch', e); } })(); // ------------------------------------------------------------------------ // Privacidade entre atendentes - versao 1.8.5 // // O projeto ja tem "agentes so veem as proprias conversas" ligado // (settings.current_agent_my_chats_only), mas no painel isso so vale para a tela // Monitor. O servidor desta instalacao devolve ao agente todas as conversas do // departamento dele (snapshot.agents), e o Historico, a exportacao em CSV e a // ficha do contato mostravam as conversas dos colegas. Esta versao do servidor nao // tem a rota de papeis (/roles), entao nao da para travar no servidor por // configuracao. // // Aqui, so para quem tem papel "agent" e com a chave do projeto ligada, toda // consulta de LISTA de conversas (GET /api/{projeto}/requests e /requests/csv) sai // com &participant= - o servidor ja aceita esse filtro e devolve // so as conversas em que ele participa, com contagem e paginas certas. // Administrador e proprietario continuam vendo tudo. // O papel e a chave sao lidos uma vez (consulta sincrona na primeira lista, depois // guardados em localStorage e renovados em segundo plano a cada abertura). // ------------------------------------------------------------------------ (function () { 'use strict'; var W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; var PID = '6a54f7da21331d0013aca02e'; var CHAVE = 'td_priv_cache'; var LISTA = new RegExp('^/api/' + PID + '/requests(/csv)?/?$'); var estado = null; var renovado = false; function sessao() { var ks = []; try { var pref = W.localStorage.getItem('td_alerta_token_key'); if (pref) ks.push(pref); } catch (e) { /* ignora */ } ks.push('tiledesk_token', 'chat_sv5__tiledeskToken', 'CHANGEIT__tiledeskToken'); for (var i = 0; i < ks.length; i++) { try { var v = W.localStorage.getItem(ks[i]); if (!v) continue; var limpo = String(v).replace(/^JWT\s+/, ''); var d = JSON.parse(decodeURIComponent(escape(atob(limpo.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))))); if (d && d._id && d.email) return { uid: d._id, cab: 'JWT ' + limpo }; } catch (e) { /* tenta a proxima */ } } return null; } function lerCache(uid) { try { var c = JSON.parse(W.localStorage.getItem(CHAVE)); if (c && c.uid === uid && c.papel) return c; } catch (e) { /* ignora */ } return null; } function montar(uid, pu, pr) { var papel = null; if (Array.isArray(pu) && pu[0]) papel = pu[0].role; else if (pu && pu.role) papel = pu.role; var so = !!(pr && pr.settings && pr.settings.current_agent_my_chats_only); return { uid: uid, papel: papel, so: so, t: Date.now() }; } function guardar(c) { if (!c || !c.papel) return; estado = c; try { W.localStorage.setItem(CHAVE, JSON.stringify(c)); } catch (e) { /* ignora */ } } function buscarSincrono(url, cab) { try { var x = new W.XMLHttpRequest(); x.__tdInterno = true; x.open('GET', url, false); x.setRequestHeader('Authorization', cab); x.send(); if (x.status === 200) return JSON.parse(x.responseText); } catch (e) { /* ignora */ } return null; } function renovarEmSegundoPlano(s) { if (renovado) return; renovado = true; var h = { Authorization: s.cab }; Promise.all([ W.fetch('/api/' + PID + '/project_users/users/' + s.uid, { headers: h }).then(function (r) { return r.ok ? r.json() : null; }), W.fetch('/api/projects/' + PID, { headers: h }).then(function (r) { return r.ok ? r.json() : null; }) ]).then(function (v) { guardar(montar(s.uid, v[0], v[1])); }).catch(function () { /* fica o que tinha */ }); } // devolve o uid se a consulta deve ser restringida, ou null function uidRestrito() { var s = sessao(); if (!s) return null; if (!estado || estado.uid !== s.uid) { estado = lerCache(s.uid); if (!estado) { guardar(montar(s.uid, buscarSincrono('/api/' + PID + '/project_users/users/' + s.uid, s.cab), buscarSincrono('/api/projects/' + PID, s.cab))); } } setTimeout(function () { renovarEmSegundoPlano(s); }, 3000); return (estado && estado.papel === 'agent' && estado.so) ? s.uid : null; } function reescrever(url) { try { var u = new URL(String(url), W.location.href); if (u.origin !== W.location.origin || !LISTA.test(u.pathname)) return url; if (u.searchParams.has('participant')) return url; var uid = uidRestrito(); if (!uid) return url; u.searchParams.set('participant', uid); return u.href; } catch (e) { return url; } } try { var XP = W.XMLHttpRequest.prototype; var abrir = XP.open; XP.open = function (metodo, url) { if (!this.__tdInterno && /^get$/i.test(String(metodo))) { var args = [].slice.call(arguments); args[1] = reescrever(url); return abrir.apply(this, args); } return abrir.apply(this, arguments); }; } catch (e) { console.log('[alerta-tiledesk] privacidade: falha no XHR', e); } try { var buscar = W.fetch; if (buscar) { W.fetch = function (entrada, op) { if (typeof entrada === 'string' && (!op || !op.method || /^get$/i.test(op.method))) { var args = [].slice.call(arguments); args[0] = reescrever(entrada); return buscar.apply(W, args); } return buscar.apply(W, arguments); }; } } catch (e) { console.log('[alerta-tiledesk] privacidade: falha no fetch', e); } // usado pela trava da tela de detalhe (dentro do alertaTiledeskPrincipal) W.__tdPrivacidade = { uidRestrito: uidRestrito }; })(); function alertaTiledeskPrincipal() { 'use strict'; // ----------------------------- configuracao ----------------------------- var PROJETO_ID = '6a54f7da21331d0013aca02e'; // Atendimento Proxxima var INTERVALO_MS = 15000; // de quanto em quanto tempo verifica var CHAVE_VISTOS = 'td_alerta_vistos'; var CHAVE_TOKEN = 'td_alerta_token_key'; var MAX_VISTOS = 300; var API = location.origin + '/api'; var VERSAO = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '1.8.8'; var log = function () { var a = ['[alerta-tiledesk]'].concat([].slice.call(arguments)); console.log.apply(console, a); }; // ------------------------- sessao do atendente -------------------------- // O script nao guarda senha nem token: reaproveita a sessao que o proprio // atendente ja tem aberta no navegador. // Decodifica o miolo do JWT. Devolve null se nao for um token legivel. function abrirToken(bruto) { if (!bruto) return null; var limpo = String(bruto).replace(/^JWT\s+/, ''); try { var meio = limpo.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); var d = JSON.parse(decodeURIComponent(escape(atob(meio)))); if (!d || !d._id) return null; return { limpo: limpo, dados: d }; } catch (e) { return null; } } // Candidatos de token no localStorage, do mais provavel para o menos. // IMPORTANTE: o token do WIDGET tambem casa com /tiledeskToken$/, mas e de // outro usuario (nao tem e-mail no miolo) e leva 403 em toda a API. Ele // costuma ser o primeiro da lista, entao pegar "o primeiro que casar" // derrubava o alerta em silencio. So valem tokens com e-mail. function candidatosDeToken() { var lista = []; var vistos = {}; var juntar = function (k) { if (!k || vistos[k]) return; vistos[k] = 1; var t = abrirToken(localStorage.getItem(k)); if (t && t.dados.email) lista.push({ chave: k, token: t }); }; try { juntar(localStorage.getItem(CHAVE_TOKEN)); } catch (e) { /* ignora */ } for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (/tiledeskToken$/.test(k) || k === 'tiledesk_token') juntar(k); } return lista; } function lerSessao() { var c = candidatosDeToken(); if (!c.length) return null; var d = c[0].token.dados; return { cabecalho: 'JWT ' + c[0].token.limpo, uid: d._id, email: d.email || '', nome: ((d.firstname || '') + ' ' + (d.lastname || '')).trim() || d.email }; } // Alem do token do widget, o navegador pode guardar um token antigo, e com // ele a API responde 503 em tudo. Testa os candidatos contra /requests/count // (que aceita chamada sem parametro) e fixa o primeiro que devolver 200. function escolherTokenBom() { var lista = candidatosDeToken(); if (lista.length < 2) return; var url = API + '/' + PROJETO_ID + '/requests/count'; var testar = function (n) { if (n >= lista.length) { log('nenhum token respondeu 200'); return; } var cab = {}; cab['Auth' + 'orization'] = 'JWT ' + lista[n].token.limpo; fetch(url, { headers: cab }) .then(function (r) { if (r.status === 200) { try { localStorage.setItem(CHAVE_TOKEN, lista[n].chave); } catch (e) {} log('token bom:', lista[n].chave); } else { testar(n + 1); } }) .catch(function () { testar(n + 1); }); }; testar(0); } // ------------------------------- memoria -------------------------------- // Ate a 1.5.2 isto era uma lista simples de request_id (so servia para saber // se a conversa era "nova"). Na 1.6.0 virou um mapa request_id -> updatedAt // (em ms), para tambem dar para perceber quando uma conversa ja conhecida // recebeu atividade nova. Uma lista antiga (formato velho) e migrada na // hora, com updatedAt = 0 - na proxima verificacao ela e tratada como // "pode ter mensagem nova" e a checagem real (verificarMensagemNova) decide // se avisa ou nao, o que é seguro mesmo lendo o formato antigo. function lerVistos() { try { var v = JSON.parse(localStorage.getItem(CHAVE_VISTOS)); if (!v) return {}; if (Array.isArray(v)) { var mapa = {}; v.forEach(function (id) { if (id) mapa[id] = 0; }); return mapa; } return v; } catch (e) { return {}; } } function gravarVistos(mapa) { try { var chaves = Object.keys(mapa); if (chaves.length > MAX_VISTOS) { // descarta as entradas com updatedAt mais antigo, mantendo MAX_VISTOS chaves.sort(function (a, b) { return (mapa[a] || 0) - (mapa[b] || 0); }); chaves.slice(0, chaves.length - MAX_VISTOS).forEach(function (k) { delete mapa[k]; }); } localStorage.setItem(CHAVE_VISTOS, JSON.stringify(mapa)); } catch (e) { /* ignora */ } } // -------------------------------- som ----------------------------------- // O Chrome cria o AudioContext suspenso ate o usuario interagir com a pagina. // Por isso mantemos um so, e destravamos no primeiro clique/tecla. var ctxAudio = null; function contexto() { var C = window.AudioContext || window.webkitAudioContext; if (!C) return null; if (!ctxAudio) { try { ctxAudio = new C(); } catch (e) { return null; } } if (ctxAudio.state === 'suspended') { try { ctxAudio.resume(); } catch (e) {} } return ctxAudio; } ['click', 'keydown'].forEach(function (ev) { document.addEventListener(ev, function () { contexto(); }, { once: false, capture: true }); }); function tocar() { var c = contexto(); if (!c) return; try { [0, 0.22].forEach(function (atraso) { var osc = c.createOscillator(), g = c.createGain(); osc.type = 'sine'; osc.frequency.value = atraso ? 1046 : 784; g.gain.setValueAtTime(0.0001, c.currentTime + atraso); g.gain.exponentialRampToValueAtTime(0.25, c.currentTime + atraso + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + atraso + 0.18); osc.connect(g); g.connect(c.destination); osc.start(c.currentTime + atraso); osc.stop(c.currentTime + atraso + 0.2); }); } catch (e) { /* ignora */ } } // ------------------- aviso dentro da propria pagina --------------------- // Nao depende da notificacao do sistema. Funciona mesmo com o Windows ou o // Chrome bloqueando notificacoes, e e visto com a aba em segundo plano // (o titulo piscando aparece na barra de abas). var tituloOriginal = document.title; var pisca = null; function piscarTitulo(txt) { if (pisca) clearInterval(pisca); var alterna = false; pisca = setInterval(function () { document.title = alterna ? tituloOriginal : txt; alterna = !alterna; }, 1000); } function pararPisca() { if (!pisca) return; clearInterval(pisca); pisca = null; document.title = tituloOriginal; } document.addEventListener('visibilitychange', function () { if (!document.hidden) pararPisca(); }); function faixaNaPagina(titulo, corpo) { var d = document.getElementById('td-alerta-faixa'); if (!d) { d = document.createElement('div'); d.id = 'td-alerta-faixa'; d.style.cssText = [ 'position:fixed', 'z-index:2147483646', 'top:14px', 'left:50%', 'transform:translateX(-50%)', 'max-width:520px', 'padding:14px 44px 14px 18px', 'border-radius:10px', 'background:#0d6efd', 'color:#fff', 'cursor:pointer', 'font:14px/1.45 system-ui,Segoe UI,Roboto,sans-serif', 'box-shadow:0 8px 26px rgba(0,0,0,.3)' ].join(';'); document.body.appendChild(d); } d.innerHTML = '' + titulo + '
' + corpo + '' + '×'; d.style.display = 'block'; d.onclick = function () { d.style.display = 'none'; pararPisca(); }; } // ----------------------------- notificacao ------------------------------ // tipo: 'novo' (atendimento atribuido agora, comportamento original) ou // 'mensagem' (conversa ja conhecida, chegou mensagem nova do cliente). // msgTexto: so vem preenchido no caso 'mensagem' - o texto da mensagem em // si, em vez do first_text (que e sempre a primeira mensagem da conversa). function avisar(req, tipo, msgTexto) { var ehNovo = tipo !== 'mensagem'; var quem = (req.snapshot && req.snapshot.lead && req.snapshot.lead.fullname) || (req.lead && req.lead.fullname) || 'Cliente'; var depto = (req.department && req.department.name) || ''; var texto = (msgTexto || req.first_text || '').replace(/\s+/g, ' ').trim().slice(0, 120); var padrao = ehNovo ? 'Conversa atribuida a voce.' : 'Nova mensagem na conversa.'; var corpo = (depto ? depto + '\n' : '') + (texto || padrao); var titulo = (ehNovo ? 'Novo atendimento' : 'Nova mensagem') + (req.ticket_id ? ' #' + req.ticket_id : '') + ' - ' + quem; // 1) som, 2) aviso dentro da pagina + titulo piscando, 3) notificacao do sistema. // Os dois primeiros sempre acontecem; o terceiro depende da permissao. tocar(); faixaNaPagina(titulo, (depto ? depto + ' — ' : '') + (texto || padrao)); piscarTitulo(ehNovo ? '🔔 Novo atendimento!' : '🔔 Nova mensagem!'); if (!('Notification' in window) || Notification.permission !== 'granted') return; try { // tag com timestamp no caso 'mensagem' para o navegador nao substituir // uma notificacao de mensagem nova pela anterior da mesma conversa. var tag = 'td-' + (req.request_id || req.ticket_id) + (ehNovo ? '' : '-msg-' + Date.now()); var n = new Notification(titulo, { body: corpo, tag: tag, requireInteraction: true, icon: location.origin + '/chat/assets/favicon/favicon-96x96.png' }); n.onclick = function () { try { window.focus(); } catch (e) {} try { if (location.pathname.indexOf('/chat') === 0 && req.request_id) { location.hash = '#/conversation-detail/' + req.request_id + '/' + encodeURIComponent(quem) + '/active'; } else if (location.pathname.indexOf('/dashboard') === 0 && req.request_id) { location.hash = '#/project/' + PROJETO_ID + '/wsrequest/' + encodeURIComponent(req.request_id) + '/messages'; } } catch (e) { /* ignora */ } n.close(); }; } catch (e) { log('falha ao notificar', e); } } // ------------------------------- ciclo ---------------------------------- // So faz a rodada silenciosa de memorizacao quando o script nunca rodou neste // navegador. Antes isso acontecia a cada recarga da pagina, e um atendimento // que caisse justo no primeiro ciclo depois do F5 passava sem aviso. var primeiraRodada = (localStorage.getItem(CHAVE_VISTOS) === null); // 1.8.6: daqui sai so o "Novo atendimento". A mensagem nova do cliente passou a ser vista // pela lista de conversas do chat (verificarDiretos, mais abaixo): o updatedAt da conversa // NAO muda quando o cliente escreve (medido em 19/10: em 16 de 50 conversas abertas a // ultima mensagem do cliente era mais nova que o updatedAt, ate 3 h). Por isso o aviso so // saia quando outra coisa mexia na conversa - por exemplo o atendente abrir o chat e // recarregar. O caminho antigo (verificarMensagemNova) fica so de reserva, para quando a // lista do chat nao responder. function verificar() { // 1.8.6: o painel lateral do chat e um iframe do /dashboard que tambem carrega o script. // Quando ele chegava primeiro, a faixa e o titulo piscando ficavam dentro do iframe, // escondidos. So a janela de cima verifica e avisa. if (window.top !== window) return; var s = lerSessao(); if (!s) return; // ainda nao logou // sort=-createdAt nao funciona nesta versao do servidor (ele usa o texto como nome de // campo); sort=updatedAt (decrescente por padrao) tambem pega conversa antiga transferida. var url = API + '/' + PROJETO_ID + '/requests' + '?participant=' + encodeURIComponent(s.uid) + '&status=200&limit=30&sort=updatedAt'; var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; fetch(url, { headers: cab }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { if (!j || !j.requests) return; var vistos = lerVistos(); var novos = []; // conversas nunca vistas -> "novo atendimento" var talvezMensagem = []; // ja vistas, mas com updatedAt mais novo j.requests.forEach(function (req) { var id = req.request_id || String(req.ticket_id || ''); if (!id) return; var u = Date.parse(req.updatedAt || req.createdAt || '') || Date.now(); if (!(id in vistos)) { vistos[id] = u; novos.push(req); return; } if (u > vistos[id]) { if (chat21EmDia()) vistos[id] = u; // a lista do chat ja cuida da mensagem nova else talvezMensagem.push(req); // reserva: lista do chat fora do ar } }); gravarVistos(vistos); pintarSelo('verificado ' + new Date().toLocaleTimeString('pt-BR')); // Na primeira rodada so memoriza o que ja existia, para nao disparar // uma enxurrada de avisos quando o atendente abre o painel. if (primeiraRodada) { primeiraRodada = false; log('primeira instalacao para ' + s.nome + ' - ' + novos.length + ' conversas ja existentes memorizadas, sem aviso'); return; } novos.forEach(function (req) { avisar(req, 'novo'); }); // updatedAt tambem muda quando o proprio atendente responde, quando // uma tag ou o departamento mudam etc. - so confirma e avisa depois // de checar que a ULTIMA mensagem da conversa e mesmo do cliente. talvezMensagem.forEach(function (req) { verificarMensagemNova(req, s); }); }) .catch(function (e) { log('erro na verificacao', e); pintarSelo('falha ao consultar'); }); } // Conversa ja conhecida com updatedAt adiantado: busca as mensagens e so // dispara o aviso se a ultima (ignorando mensagens de sistema) for do // cliente - nunca do proprio atendente nem de outro agente/bot no grupo. function verificarMensagemNova(req, s) { var url = API + '/' + PROJETO_ID + '/requests/' + encodeURIComponent(req.request_id) + '/messages'; var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; fetch(url, { headers: cab }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (lista) { if (!lista || !lista.length) return; var ultima = null; for (var i = lista.length - 1; i >= 0; i--) { var m = lista[i]; if (m && m.sender !== 'system' && !(m.attributes && m.attributes.subtype === 'info')) { ultima = m; break; } } // So marca como processado (vistos[id] = u) depois de conseguir ler a // lista de mensagens - se o fetch falhar (cai no .catch abaixo) ou // vier vazio, o registro fica como estava e a proxima rodada tenta de // novo. Isso vale tanto quando avisa quanto quando decide nao avisar // (ultima mensagem do proprio atendente) - sem isso, uma conversa em // que so o atendente respondeu ficaria sendo re-checada a cada 15s // para sempre, porque o updatedAt dela nunca deixa de estar "a frente". if (!ultima) return; var vistos = lerVistos(); var u = Date.parse(req.updatedAt || req.createdAt || '') || Date.now(); vistos[req.request_id] = u; gravarVistos(vistos); var leadId = req.lead && req.lead.lead_id; var doCliente = leadId ? (ultima.sender === leadId) : (ultima.sender !== s.uid); if (!doCliente) return; // ultima mensagem foi do atendente - nao avisa // mesmo registro da lista do chat: a mesma mensagem nao avisa duas vezes var t1 = Date.parse(ultima.createdAt || '') || Date.now(); var cl = lerClientes(); if (cl[req.request_id] !== undefined && t1 <= cl[req.request_id]) return; cl[req.request_id] = t1 + 5000; // folga: o horario do chat21 difere uns segundos gravarClientes(cl); avisar(req, 'mensagem', ultima.text); }) .catch(function (e) { log('erro ao checar mensagem nova de ' + req.request_id, e); }); } // ------------------------------ selo de status -------------------------- // Fica visivel no canto para dar para conferir, sem abrir nada, se o script // esta rodando, se a permissao foi dada e quando foi a ultima verificacao. var selo = null, seloTexto = null; function montarSelo() { if (selo || !document.body) return; selo = document.createElement('div'); selo.id = 'td-alerta-selo'; selo.style.cssText = [ 'position:fixed', 'z-index:2147483647', 'left:14px', 'bottom:14px', 'padding:8px 12px', 'border-radius:20px', 'cursor:pointer', 'font:12px/1.3 system-ui,Segoe UI,Roboto,sans-serif', 'color:#fff', 'background:#198754', 'box-shadow:0 3px 12px rgba(0,0,0,.22)', 'user-select:none', 'opacity:.92' ].join(';'); seloTexto = document.createElement('span'); selo.appendChild(seloTexto); selo.title = 'Clique para testar o aviso'; selo.onclick = aoClicarSelo; document.body.appendChild(selo); pintarSelo('iniciando...'); } function pintarSelo(txt) { if (!seloTexto) return; var perm = ('Notification' in window) ? Notification.permission : 'indisponivel'; var cor = '#198754', prefixo = 'alerta ativo'; if (perm === 'default') { cor = '#0d6efd'; prefixo = 'clique para liberar o aviso'; } if (perm === 'denied') { cor = '#dc3545'; prefixo = 'notificacao bloqueada'; } if (perm === 'indisponivel') { cor = '#6c757d'; prefixo = 'navegador sem notificacao'; } if (txt !== undefined) ultimoTextoSelo = txt; if (document.querySelectorAll('#td-alerta-selo').length > 1) { // outra copia do script (instalada antes, com outro nome) tambem desenhou o selo selo.style.background = '#dc3545'; selo.title = 'Ha mais de um script do alerta instalado neste Chrome'; seloTexto.textContent = 'alerta instalado 2 vezes (v' + VERSAO + ') - apague o antigo no Tampermonkey'; return; } if (versaoNova) { selo.style.background = '#fd7e14'; selo.title = 'Clique para atualizar o script'; seloTexto.textContent = 'atualizacao disponivel: v' + VERSAO + ' -> v' + versaoNova + ' - clique aqui'; return; } selo.title = 'Clique para testar o aviso'; selo.style.background = cor; seloTexto.textContent = prefixo + ' v' + VERSAO + (ultimoTextoSelo ? ' - ' + ultimoTextoSelo : ''); } // ------------------------- aviso de versao nova (1.8.1) ------------------------- // O Tampermonkey so confere atualizacao de tempos em tempos (o padrao e 1x por dia), e // em algumas maquinas nem isso. O site publica versao.svg com a versao atual escrita na // LARGURA da imagem (1.8.1 -> 10801). Imagem de outro dominio e permitida pela politica // do chat (img-src https:), e a largura pode ser lida sem CORS. Se a publicada for maior // que a instalada, o selo fica laranja e o clique abre a pagina de atualizacao. // O clique abre a pagina de ajuda (e nao o .user.js direto): no Chrome 151+ o Tampermonkey as // vezes trava na pagina intermediaria tampermonkey.net/script_installation.php (bug aberto, // Tampermonkey/tampermonkey#2863), e a ajuda explica os caminhos alternativos. var URL_SCRIPT = 'https://alerta-tiledesk.pages.dev/#atualizar'; var URL_VERSAO = 'https://alerta-tiledesk.pages.dev/versao.svg'; var versaoNova = null; var ultimoTextoSelo = ''; function numeroDaVersao(v) { var p = String(v || '').split('.'); return (parseInt(p[0], 10) || 0) * 10000 + (parseInt(p[1], 10) || 0) * 100 + (parseInt(p[2], 10) || 0); } function conferirVersao() { var img = new Image(); img.onload = function () { var n = img.naturalWidth; var antes = versaoNova; versaoNova = (n > numeroDaVersao(VERSAO)) ? Math.floor(n / 10000) + '.' + Math.floor((n % 10000) / 100) + '.' + (n % 100) : null; if (versaoNova !== antes) { if (versaoNova) log('versao nova publicada: ' + versaoNova + ' (instalada: ' + VERSAO + ')'); pintarSelo(); } }; img.onerror = function () { /* sem rede ou arquivo ausente: nao avisa nada */ }; img.src = URL_VERSAO + '?t=' + Date.now(); } function aoClicarSelo() { if (versaoNova) { window.open(URL_SCRIPT, '_blank'); return; } contexto(); // destrava o som if (!('Notification' in window)) return; if (Notification.permission === 'default') { Notification.requestPermission().then(function (p) { pintarSelo(p === 'granted' ? 'liberado' : 'recusado'); if (p === 'granted') { tocar(); new Notification('Aviso ativado', { body: 'Voce sera avisado quando cair um atendimento para voce.' }); } }); return; } if (Notification.permission === 'denied') { alert('A notificacao esta bloqueada para este site.\n\n' + 'Para liberar: clique no cadeado ao lado do endereco > Configuracoes do site > ' + 'Notificacoes > Permitir. Depois recarregue a pagina.'); return; } tocar(); faixaNaPagina('Teste do alerta', 'Som e aviso na pagina funcionando.'); var n = new Notification('Teste do alerta', { body: 'Notificacao e som estao funcionando.' }); setTimeout(function () { try { n.close(); } catch (e) {} }, 6000); } // ------------------- conserto da rolagem do historico ------------------- // Defeito do painel: o componente nasce com // display:inline e altura 0, entao a cadeia de alturas quebra e a caixa das // mensagens (.conversation-wpr) fica com overflow-y:visible. O historico // existe, mas nao ha barra de rolagem: so aparece quando a janela encolhe. // As tres linhas abaixo devolvem a rolagem sem mexer em mais nada. function consertarRolagem() { if (document.getElementById('td-fix-rolagem')) return; var st = document.createElement('style'); st.id = 'td-fix-rolagem'; // 1.8.7: barra de rolagem da lista de conversas (coluna da esquerda do chat). A lista // rola, mas o CSS do chat esconde a barra (ion-content::-webkit-scrollbar{display:none}), // e sem roda do mouse nao dava para chegar nas conversas mais antigas. scrollbar-width e // scrollbar-color tem prioridade sobre ::-webkit-scrollbar no Chrome 121+, entao a barra // volta fina; overflow-y:auto (em vez do scroll do chat) so a mostra quando ha o que rolar. st.textContent = 'ion-conversation-detail{display:block;height:100%;}' + '.conversation-wpr{overflow-y:auto;overflow-x:hidden;}' + 'ion-content#convs-list,ion-content.list-avatar-page{overflow-y:auto!important;overflow-x:hidden!important;' + 'scrollbar-width:thin!important;scrollbar-color:#9aa4b1 transparent!important;}'; (document.head || document.documentElement).appendChild(st); log('rolagem do historico e da lista de conversas corrigida'); } // -------------------------------- pausas -------------------------------- // O Tiledesk so tem tres estados (acessivel / indisponivel / inativo) e a // lista e fixa no codigo do painel. As pausas abaixo sao uma camada nossa: // deixam o atendente indisponivel de verdade (para de receber conversa) e // registram inicio e fim numa planilha, para render relatorio de tempo. // A URL do App da Web do Apps Script. Da para trocar sem republicar o // script: basta gravar localStorage.td_pausas_url no navegador. var PAUSAS_URL = 'https://script.google.com/macros/s/AKfycbz870CJhZQS1lZda5l8k05wPluFjYULRoHKu-l54JYh_I0T-4dguzT3ST-Nukm2hMr3tg/exec'; try { var urlSalva = localStorage.getItem('td_pausas_url'); if (urlSalva && urlSalva.indexOf('http') === 0) PAUSAS_URL = urlSalva; } catch (e) { /* ignora */ } var PAUSAS_TOKEN = 'proxxima-pausas'; var CHAVE_PAUSA = 'td_pausa_atual'; var TIPOS_PAUSA = [ { id: 'almoco', rotulo: 'Pausa almoco', cor: '#F5C518', letra: '#3d2f00' }, { id: 'dez', rotulo: 'Pausa 10 minutos', cor: '#F07B1D', letra: '#fff' } ]; var painelPausa = null, textoPausa = null, tickPausa = null; function lerPausa() { try { return JSON.parse(localStorage.getItem(CHAVE_PAUSA)); } catch (e) { return null; } } function gravarPausa(p) { try { if (p) localStorage.setItem(CHAVE_PAUSA, JSON.stringify(p)); else localStorage.removeItem(CHAVE_PAUSA); } catch (e) { /* ignora */ } } // text/plain de proposito: evita o preflight CORS, que o Apps Script nao responde. function registrarPausa(evento, tipo) { var s = lerSessao(); if (!s || PAUSAS_URL.indexOf('http') !== 0) return; var corpo = JSON.stringify({ token: PAUSAS_TOKEN, evento: evento, tipo: tipo || '', nome: s.nome, email: s.email }); try { if (typeof GM_xmlhttpRequest === 'function') { // GM_xmlhttpRequest roda fora da pagina, entao nao e bloqueado pelo // Content-Security-Policy do Tiledesk (que so libera conexoes pro // proprio dominio). O fetch() direto era bloqueado em silencio aqui. GM_xmlhttpRequest({ method: 'POST', url: PAUSAS_URL, headers: { 'Content-Type': 'text/plain;charset=utf-8' }, data: corpo, onerror: function (e) { log('falha ao registrar pausa (GM)', e); }, ontimeout: function () { log('timeout ao registrar pausa'); } }); } else { // sem GM_xmlhttpRequest (script instalado com @grant none): mantem o // fetch antigo como fallback, mesmo sabendo que o CSP atual bloqueia. fetch(PAUSAS_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body: corpo }).catch(function () {}); } } catch (e) { log('falha ao registrar pausa', e); } } // Se o seletor do painel estiver na tela, usa ele (mantem o Angular em dia). // Se nao estiver, fala direto com a API. function definirDisponibilidade(disponivel) { var ng = document.querySelector('ng-select.teammate-status-in-drawer'); if (ng) { var cx = ng.querySelector('.ng-select-container'); if (cx) { cx.click(); var alvo = disponivel ? /acess/i : /indispon/i; setTimeout(function () { var op = [].slice.call(document.querySelectorAll('.ng-option')) .filter(function (e) { return alvo.test(e.textContent || ''); })[0]; if (op) { op.click(); return; } porApi(disponivel); }, 350); return; } } porApi(disponivel); } function porApi(disponivel) { var s = lerSessao(); if (!s) return; var cab = { 'Content-Type': 'application/json' }; cab['Auth' + 'orization'] = s.cabecalho; fetch(API + '/' + PROJETO_ID + '/project_users/', { method: 'PUT', headers: cab, body: JSON.stringify({ user_available: !!disponivel, profileStatus: '' }) }).catch(function () {}); } function entrarEmPausa(id) { var t = TIPOS_PAUSA.filter(function (x) { return x.id === id; })[0]; if (!t) return; gravarPausa({ tipo: t.id, rotulo: t.rotulo, inicio: Date.now() }); definirDisponibilidade(false); registrarPausa('inicio', t.id); desenharPausa(); log('pausa iniciada:', t.rotulo); } function sairDaPausa() { var p = lerPausa(); if (!p) return; gravarPausa(null); definirDisponibilidade(true); registrarPausa('fim', p.tipo); desenharPausa(); log('pausa encerrada'); } function decorrido(ms) { var s = Math.max(0, Math.floor(ms / 1000)); var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60; var dois = function (n) { return (n < 10 ? '0' : '') + n; }; return (h ? dois(h) + ':' : '') + dois(m) + ':' + dois(r); } // Tarja fixa enquanto a pausa estiver correndo, para ninguem esquecer. function desenharPausa() { var p = lerPausa(); if (!painelPausa && document.body) { painelPausa = document.createElement('div'); painelPausa.id = 'td-pausa-painel'; painelPausa.style.cssText = [ 'position:fixed', 'z-index:2147483647', 'left:14px', 'bottom:56px', 'padding:10px 14px', 'border-radius:20px', 'display:none', 'font:600 13px/1.3 system-ui,Segoe UI,Roboto,sans-serif', 'box-shadow:0 3px 12px rgba(0,0,0,.25)', 'user-select:none', 'align-items:center', 'gap:10px' ].join(';'); textoPausa = document.createElement('span'); var bt = document.createElement('button'); bt.textContent = 'Voltar'; bt.style.cssText = [ 'border:0', 'border-radius:14px', 'padding:5px 12px', 'cursor:pointer', 'font:600 12px system-ui,Segoe UI,Roboto,sans-serif', 'background:rgba(0,0,0,.22)', 'color:inherit' ].join(';'); bt.onclick = sairDaPausa; painelPausa.appendChild(textoPausa); painelPausa.appendChild(bt); document.body.appendChild(painelPausa); } if (!painelPausa) return; if (!p) { painelPausa.style.display = 'none'; if (tickPausa) { clearInterval(tickPausa); tickPausa = null; } return; } var t = TIPOS_PAUSA.filter(function (x) { return x.id === p.tipo; })[0] || TIPOS_PAUSA[0]; painelPausa.style.display = 'flex'; painelPausa.style.background = t.cor; painelPausa.style.color = t.letra; var atualizar = function () { var atual = lerPausa(); if (!atual) { desenharPausa(); return; } textoPausa.textContent = atual.rotulo + ' - ' + decorrido(Date.now() - atual.inicio); }; atualizar(); if (!tickPausa) tickPausa = setInterval(atualizar, 1000); } // Os dois botoes dentro do painel de perfil, logo abaixo do seletor de status. function montarBotoesDePausa() { var sec = document.querySelector('section.availability-section'); if (!sec || sec.querySelector('#td-pausa-botoes')) return; var cx = document.createElement('div'); cx.id = 'td-pausa-botoes'; cx.style.cssText = 'display:flex;flex-direction:column;gap:8px;margin-top:10px'; TIPOS_PAUSA.forEach(function (t) { var b = document.createElement('button'); b.textContent = t.rotulo; b.style.cssText = [ 'border:0', 'border-radius:8px', 'padding:10px 12px', 'cursor:pointer', 'font:600 13px system-ui,Segoe UI,Roboto,sans-serif', 'text-align:left', 'background:' + t.cor, 'color:' + t.letra ].join(';'); b.onclick = function (ev) { ev.preventDefault(); ev.stopPropagation(); entrarEmPausa(t.id); }; cx.appendChild(b); }); var volta = document.createElement('button'); volta.textContent = 'Encerrar pausa'; volta.style.cssText = [ 'border:1px solid rgba(128,128,128,.5)', 'border-radius:8px', 'padding:9px 12px', 'cursor:pointer', 'font:600 13px system-ui,Segoe UI,Roboto,sans-serif', 'background:transparent', 'color:inherit' ].join(';'); volta.onclick = function (ev) { ev.preventDefault(); ev.stopPropagation(); sairDaPausa(); }; volta.style.display = lerPausa() ? 'block' : 'none'; cx.appendChild(volta); sec.appendChild(cx); } // Se o atendente voltar a "acessivel" pelo seletor nativo, encerra a pausa. // Só vale para a TRANSICAO (estava noutro estado e passou a acessivel). Ler o // estado parado nao serve: ao entrar em pausa o seletor ainda mostra // "acessivel" por uns instantes, e isso cancelava a pausa recem-criada. var ultimoTextoStatus = ''; var CARENCIA_MS = 6000; function vigiarSeletorDeStatus() { var ng = document.querySelector('ng-select.teammate-status-in-drawer'); if (!ng) { ultimoTextoStatus = ''; return; } var txt = (ng.textContent || '').toLowerCase(); var agoraAcessivel = txt.indexOf('acess') >= 0; var antesAcessivel = ultimoTextoStatus.indexOf('acess') >= 0; var tinhaTexto = !!ultimoTextoStatus; ultimoTextoStatus = txt; var p = lerPausa(); if (!p) return; if (Date.now() - p.inicio < CARENCIA_MS) return; // carencia do inicio if (!tinhaTexto) return; // primeira leitura nao conta if (agoraAcessivel && !antesAcessivel) sairDaPausa(); } function ligarPausas() { desenharPausa(); montarBotoesDePausa(); try { new MutationObserver(function () { montarBotoesDePausa(); vigiarSeletorDeStatus(); }).observe(document.body, { childList: true, subtree: true }); } catch (e) { /* ignora */ } } // --------------------- anexo no bate-papo direto (1.7.1) --------------------- // No bate-papo direto (atendente com atendente) o chat nao descobre o projeto da // conversa, entao nunca preenche a lista de tipos aceitos do campo de mensagem // (fileUploadAccept fica undefined). Ao escolher o arquivo, a checagem do chat faz // undefined.split(...) e quebra em silencio: nao abre a previa, nao envia nada. // Aqui a lista e preenchida com a mesma configuracao do projeto // (settings.allowed_upload_extentions), so quando estiver vazia. var ACEITOS_PADRAO = '.png,.jpg,.jpeg,.gif,.pdf'; var aceitosProjeto = null; function carregarAceitosDoProjeto() { var s = lerSessao(); if (!s) return; var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; fetch(API + '/projects/' + PROJETO_ID, { headers: cab }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { var v = j && j.settings && j.settings.allowed_upload_extentions; if (typeof v === 'string' && v.trim()) aceitosProjeto = v; }) .catch(function () { /* fica o padrao */ }); } function consertarAnexoBatePapoDireto() { if (location.pathname.indexOf('/chat/') !== 0) return; var areas = document.querySelectorAll('app-message-text-area'); for (var a = 0; a < areas.length; a++) { var ctx = areas[a].__ngContext__; if (!Array.isArray(ctx)) continue; for (var i = 0; i < ctx.length; i++) { var c = ctx[i]; if (!c || typeof c !== 'object' || Array.isArray(c) || c instanceof Node) continue; if (!('fileUploadAccept' in c) || typeof c.presentModal !== 'function') continue; var atual = c.fileUploadAccept; if (typeof atual === 'string' && atual && atual !== 'undefined') continue; c.fileUploadAccept = aceitosProjeto || ACEITOS_PADRAO; var inp = areas[a].querySelector('input[type=file]'); if (inp) inp.setAttribute('accept', c.fileUploadAccept); log('anexo liberado no bate-papo direto'); } } } // ------------------------------- audio (1.8.0) ------------------------------- // O chat 3.4.22 nao tem gravador de audio. Este bloco poe um microfone ao lado do // clipe. A gravacao vira MP3 (lamejs 1.2.1, copia hospedada junto do script e carregada pelo @require) e segue por um de // dois caminhos: // // - Conversa de WhatsApp: o conector do servidor descarta anexo de audio vindo do // atendente (TiledeskWhatsappTranslator.toWhatsapp so trata imagem, video e // application/*). Enquanto for assim, MODO_AUDIO_WHATSAPP = 'link': o MP3 sobe // como arquivo e o cliente recebe uma mensagem de texto com o link para ouvir. // Quando o conector aceitar audio, trocar para 'audio' (vai como audio de verdade). // - Bate-papo direto e demais canais: o arquivo entra pelo proprio fluxo de anexo do // chat (abre a previa, o atendente confirma) e aparece com o player de audio. var MODO_AUDIO_WHATSAPP = 'link'; var AUDIO_MAX_SEG = 300; // corta sozinho em 5 minutos var AUDIO_KBPS = 64; var gravacao = null; // { fase, rec, stream, partes, inicio, conversa, tipo, limite } var canalPorConversa = {}; var SVG_MIC = ''; var SVG_OK = ''; var SVG_X = ''; function conversaAtual() { var m = location.hash.match(/conversation-detail\/([^\/]+)\/([^\/]*)\/([a-z]+)/); if (!m) return null; var id = m[1], nome = m[2]; try { id = decodeURIComponent(id); } catch (e) { /* fica como veio */ } try { nome = decodeURIComponent(nome); } catch (e) { /* fica como veio */ } return { id: id, nome: nome, estado: m[3] }; } function componenteDoCampo(area) { var ctx = area && area.__ngContext__; if (!Array.isArray(ctx)) return null; for (var i = 0; i < ctx.length; i++) { var c = ctx[i]; if (!c || typeof c !== 'object' || Array.isArray(c) || c instanceof Node) continue; if ('fileUploadAccept' in c && typeof c.presentModal === 'function') return c; } return null; } function canalDaConversa(id, s) { if (id.indexOf('support-group-') !== 0) return Promise.resolve('direto'); if (canalPorConversa[id]) return Promise.resolve(canalPorConversa[id]); if (id.indexOf('-wab-') >= 0) { canalPorConversa[id] = 'whatsapp'; return Promise.resolve('whatsapp'); } var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; return fetch(API + '/' + PROJETO_ID + '/requests/' + encodeURIComponent(id), { headers: cab }) .then(function (r) { if (!r.ok) throw new Error('nao consegui identificar o canal da conversa (HTTP ' + r.status + ')'); return r.json(); }) .then(function (j) { var c = (j && j.channel && j.channel.name === 'whatsapp') ? 'whatsapp' : 'outro'; canalPorConversa[id] = c; return c; }); } function tempoGravacao() { var seg = gravacao ? Math.floor((Date.now() - gravacao.inicio) / 1000) : 0; return Math.floor(seg / 60) + ':' + ('0' + (seg % 60)).slice(-2); } function avisoAudio(texto, erro) { var a = document.getElementById('td-audio-aviso'); if (!a) { a = document.createElement('div'); a.id = 'td-audio-aviso'; a.style.cssText = 'position:fixed;left:50%;bottom:90px;transform:translateX(-50%);z-index:2147483646;' + 'max-width:460px;padding:10px 16px;border-radius:8px;font:14px/1.4 system-ui,Segoe UI,Roboto,sans-serif;' + 'color:#fff;box-shadow:0 4px 14px rgba(0,0,0,.25)'; document.body.appendChild(a); } a.style.background = erro ? '#dc3545' : '#198754'; a.textContent = texto; a.style.display = 'block'; clearTimeout(a._t); a._t = setTimeout(function () { a.style.display = 'none'; }, erro ? 9000 : 5000); } function botaoAudio(svg, titulo, cor) { var b = document.createElement('button'); b.type = 'button'; b.title = titulo; b.setAttribute('aria-label', titulo); b.innerHTML = svg; b.style.cssText = 'display:flex;align-items:center;justify-content:center;width:30px;height:30px;' + 'padding:0;margin:0;border:0;border-radius:50%;background:transparent;cursor:pointer;color:' + cor; return b; } function desenharAudio(w) { var fase = gravacao ? gravacao.fase : 'parado'; if (w.getAttribute('data-fase') === fase) { if (fase === 'gravando') { var t = w.querySelector('.td-audio-tempo'); if (t) t.textContent = tempoGravacao(); } return; } w.setAttribute('data-fase', fase); w.innerHTML = ''; if (fase === 'parado') { var mic = botaoAudio(SVG_MIC, 'Gravar áudio', '#8796af'); mic.addEventListener('click', iniciarGravacao); w.appendChild(mic); } else if (fase === 'gravando') { var tempo = document.createElement('span'); tempo.style.cssText = 'display:flex;align-items:center;gap:6px;padding:0 6px;color:#dc3545;' + 'font:600 13px system-ui,Segoe UI,Roboto,sans-serif;white-space:nowrap'; tempo.innerHTML = '' + '' + tempoGravacao() + ''; var ok = botaoAudio(SVG_OK, 'Parar e enviar', '#198754'); ok.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); pararGravacao(true); }); var x = botaoAudio(SVG_X, 'Descartar gravação', '#dc3545'); x.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); pararGravacao(false); }); w.appendChild(tempo); w.appendChild(ok); w.appendChild(x); } else { var env = document.createElement('span'); env.style.cssText = 'padding:0 6px;color:#6c757d;font:13px system-ui,Segoe UI,Roboto,sans-serif;white-space:nowrap'; env.textContent = 'enviando áudio...'; w.appendChild(env); } } function montarBotaoAudio() { if (location.pathname.indexOf('/chat/') !== 0) return; var conv = conversaAtual(); if (gravacao && gravacao.fase === 'gravando' && (!conv || conv.id !== gravacao.conversa.id)) { pararGravacao(false); avisoAudio('Gravação descartada: você mudou de conversa antes de enviar.', true); } var existente = document.querySelector('.td-audio-wpr'); var bl = document.querySelector('app-message-text-area div.buttons-left'); var pode = bl && conv && conv.estado === 'active' && navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder; if (!pode) { if (existente && !gravacao) existente.remove(); return; } if (existente && existente.parentElement !== bl) { existente.remove(); existente = null; } if (!existente) { existente = document.createElement('div'); existente.className = 'td-audio-wpr'; existente.style.cssText = 'display:flex;align-items:center;margin:4px 2px'; bl.appendChild(existente); } desenharAudio(existente); } function iniciarGravacao(ev) { if (ev) { ev.preventDefault(); ev.stopPropagation(); } if (gravacao) return; var conv = conversaAtual(); if (!conv) return; navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { var tipos = ['audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/webm;codecs=opus', 'audio/webm']; var tipo = null; for (var i = 0; i < tipos.length; i++) { if (MediaRecorder.isTypeSupported(tipos[i])) { tipo = tipos[i]; break; } } var rec = tipo ? new MediaRecorder(stream, { mimeType: tipo }) : new MediaRecorder(stream); var g = { fase: 'gravando', rec: rec, stream: stream, partes: [], inicio: Date.now(), conversa: conv, tipo: rec.mimeType || tipo || 'audio/webm' }; rec.ondataavailable = function (e) { if (e.data && e.data.size) g.partes.push(e.data); }; rec.start(1000); g.limite = setTimeout(function () { pararGravacao(true); }, AUDIO_MAX_SEG * 1000); gravacao = g; log('gravando audio (' + g.tipo + ')'); montarBotaoAudio(); }).catch(function (e) { log('microfone indisponivel', e); avisoAudio('Não consegui usar o microfone. Libere o microfone para este site (cadeado na barra de endereço) e tente de novo.', true); }); } function pararGravacao(enviar) { var g = gravacao; if (!g || g.fase !== 'gravando') return; clearTimeout(g.limite); var parou = new Promise(function (res) { g.rec.onstop = res; }); try { g.rec.stop(); } catch (e) { /* ja parado */ } g.stream.getTracks().forEach(function (t) { try { t.stop(); } catch (e) { /* ignora */ } }); if (!enviar) { gravacao = null; montarBotaoAudio(); return; } g.fase = 'enviando'; montarBotaoAudio(); parou.then(function () { var bruto = new Blob(g.partes, { type: g.tipo.split(';')[0] }); if (!bruto.size || Date.now() - g.inicio < 1000) throw new Error('gravação muito curta.'); return paraMp3(bruto).catch(function (e) { log('sem conversao para mp3, vai o arquivo original', e); return bruto; }); }).then(function (arquivo) { return enviarAudio(arquivo, g.conversa); }).then(function (resultado) { if (resultado === 'previa') avisoAudio('Confira e confirme o envio na janela de prévia.'); else avisoAudio('Áudio enviado.'); }).catch(function (e) { log('falha ao enviar audio', e); avisoAudio('O áudio não foi enviado: ' + ((e && e.message) || e), true); }).then(function () { gravacao = null; montarBotaoAudio(); }); } function paraMp3(blob) { var L = (typeof lamejs !== 'undefined') ? lamejs : window.lamejs; if (!L || typeof L.Mp3Encoder !== 'function') return Promise.reject(new Error('lamejs ausente')); var Ctx = window.AudioContext || window.webkitAudioContext; var ac = new Ctx({ sampleRate: 44100 }); return blob.arrayBuffer().then(function (ab) { return ac.decodeAudioData(ab); }).then(function (buf) { try { ac.close(); } catch (e) { /* ignora */ } var n = buf.length, ch = buf.numberOfChannels, canais = []; for (var c = 0; c < ch; c++) canais.push(buf.getChannelData(c)); var pcm = new Int16Array(n); for (var i = 0; i < n; i++) { var v = 0; for (var k = 0; k < ch; k++) v += canais[k][i]; v = v / ch; if (v > 1) v = 1; else if (v < -1) v = -1; pcm[i] = v < 0 ? v * 0x8000 : v * 0x7FFF; } var enc = new L.Mp3Encoder(1, buf.sampleRate, AUDIO_KBPS); var partes = []; for (var j = 0; j < n; j += 1152) { var b = enc.encodeBuffer(pcm.subarray(j, j + 1152)); if (b.length) partes.push(new Uint8Array(b)); } var f = enc.flush(); if (f.length) partes.push(new Uint8Array(f)); return new Blob(partes, { type: 'audio/mpeg' }); }, function (e) { try { ac.close(); } catch (x) { /* ignora */ } throw e; }); } function nomeAudio(tipo) { var d = new Date(); var p = function (x) { return (x < 10 ? '0' : '') + x; }; var ext = /mpeg/.test(tipo) ? 'mp3' : (/mp4/.test(tipo) ? 'm4a' : 'webm'); return 'audio-' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + '.' + ext; } function enviarAudio(blob, conv) { var s = lerSessao(); if (!s) return Promise.reject(new Error('sessão do Tiledesk não encontrada.')); var tipo = blob.type || 'audio/mpeg'; var arquivo = new File([blob], nomeAudio(tipo), { type: tipo }); return canalDaConversa(conv.id, s).then(function (canal) { if (canal === 'whatsapp') return enviarAudioWhatsapp(arquivo, conv, s); var atual = conversaAtual(); if (!atual || atual.id !== conv.id) throw new Error('a conversa mudou antes do envio.'); return anexarPeloChat(arquivo); }); } function anexarPeloChat(arquivo) { var area = document.querySelector('app-message-text-area'); var inp = area && area.querySelector('input[type=file]'); var comp = componenteDoCampo(area); if (!inp || !comp) return Promise.reject(new Error('campo de anexo do chat não encontrado.')); var antes = comp.fileUploadAccept; var base = (typeof antes === 'string' && antes && antes !== 'undefined') ? antes : (aceitosProjeto || ACEITOS_PADRAO); comp.fileUploadAccept = base + ',' + arquivo.type; // so durante a checagem do chat try { var dt = new DataTransfer(); dt.items.add(arquivo); inp.files = dt.files; inp.dispatchEvent(new Event('change', { bubbles: true })); } finally { setTimeout(function () { comp.fileUploadAccept = base; }, 0); } return Promise.resolve('previa'); } function enviarAudioWhatsapp(arquivo, conv, s) { var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; var fd = new FormData(); fd.append('file', arquivo, arquivo.name); return fetch(API + '/files/users', { method: 'POST', headers: cab, body: fd }) .then(function (r) { if (!r.ok) throw new Error('falha ao subir o arquivo (HTTP ' + r.status + ').'); return r.json(); }) .then(function (j) { if (!j || !j.filename) throw new Error('o servidor não devolveu o arquivo.'); var caminho = encodeURI(j.filename); var src = location.origin + '/api/files?path=' + caminho; var baixar = location.origin + '/api/files/download?path=' + caminho; var corpo; if (MODO_AUDIO_WHATSAPP === 'audio') { corpo = { text: arquivo.name, type: 'file', senderFullname: s.nome, metadata: { name: arquivo.name, type: arquivo.type, src: src, downloadURL: baixar } }; } else { corpo = { text: '🎤 Mensagem de áudio — toque no link para ouvir:\n' + baixar, senderFullname: s.nome }; } var cab2 = {}; cab2['Auth' + 'orization'] = s.cabecalho; cab2['Content-Type'] = 'application/json'; return fetch(API + '/' + PROJETO_ID + '/requests/' + encodeURIComponent(conv.id) + '/messages', { method: 'POST', headers: cab2, body: JSON.stringify(corpo) }); }) .then(function (r) { if (!r.ok) throw new Error('falha ao publicar a mensagem (HTTP ' + r.status + ').'); log('audio enviado para ' + conv.id + ' (modo ' + MODO_AUDIO_WHATSAPP + ')'); return 'enviado'; }); } // ------------- respostas predefinidas no bate-papo direto (1.8.2) ------------- // O chat desliga o raio (respostas predefinidas) fora das conversas de cliente: o botao // tem [disabled]="!conversationWith?.startsWith('support-group')", e no bate-papo direto // o chat nem saberia de qual projeto buscar as respostas. Aqui, so no bate-papo direto, // o clique no raio abre uma lista propria com as respostas do projeto // (GET /{projeto}/canned/ - as compartilhadas e as do proprio atendente) e o texto // escolhido vai para o campo de mensagem; quem envia continua sendo o atendente. // Nas conversas de cliente nada muda: o raio do proprio chat ja funciona. var respostasCache = null; var respostasQuando = 0; var listaRespostas = null; function carregarRespostas(s) { if (respostasCache && Date.now() - respostasQuando < 5 * 60 * 1000) return Promise.resolve(respostasCache); var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; return fetch(API + '/' + PROJETO_ID + '/canned/', { headers: cab }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (j) { var lista = (Array.isArray(j) ? j : []).filter(function (c) { return c && typeof c.text === 'string' && c.text; }); lista.sort(function (a, b) { return String(a.title || '').localeCompare(String(b.title || ''), 'pt-BR'); }); respostasCache = lista; respostasQuando = Date.now(); return lista; }); } function estiloRespostas() { if (document.getElementById('td-canned-estilo')) return; var st = document.createElement('style'); st.id = 'td-canned-estilo'; st.textContent = '.td-canned-direto{cursor:pointer}' + '.td-canned-direto ion-button.canned-responses-btn{pointer-events:none;opacity:1!important}' + '.td-canned-direto ion-button.canned-responses-btn::part(native){opacity:1!important}' + '#td-canned-lista{position:fixed;z-index:2147483646;width:380px;max-width:calc(100vw - 24px);' + 'max-height:360px;display:flex;flex-direction:column;background:#fff;border:1px solid #dee2e6;' + 'border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.18);font:14px/1.4 system-ui,Segoe UI,Roboto,sans-serif;color:#1b1f24}' + '#td-canned-lista .tdc-topo{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid #eef0f3}' + '#td-canned-lista .tdc-topo b{flex:1;font-size:13px}' + '#td-canned-lista .tdc-fechar{border:0;background:transparent;font-size:18px;line-height:1;cursor:pointer;color:#6c757d}' + '#td-canned-lista input{margin:8px 12px;padding:7px 10px;border:1px solid #dee2e6;border-radius:6px;font:inherit;outline:none}' + '#td-canned-lista input:focus{border-color:#a80062}' + '#td-canned-lista ul{list-style:none;margin:0;padding:0 0 6px;overflow-y:auto}' + '#td-canned-lista li{padding:8px 12px;cursor:pointer;border-top:1px solid #f3f4f6}' + '#td-canned-lista li:hover,#td-canned-lista li.tdc-ativo{background:#fbeef5}' + '#td-canned-lista li .tdc-titulo{font-weight:600;font-size:13px}' + '#td-canned-lista li .tdc-previa{color:#6c757d;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '#td-canned-lista .tdc-vazio{padding:14px 12px;color:#6c757d;font-size:13px}'; (document.head || document.documentElement).appendChild(st); } function fecharListaRespostas() { if (listaRespostas) { listaRespostas.remove(); listaRespostas = null; } document.removeEventListener('mousedown', cliqueForaDaLista, true); } function cliqueForaDaLista(ev) { if (!listaRespostas) return; if (listaRespostas.contains(ev.target)) return; if (ev.target.closest && ev.target.closest('.td-canned-direto')) return; fecharListaRespostas(); } function trocarMarcadores(texto, conv) { var s = lerSessao(); return String(texto) .replace(/\$agent_name/g, (s && s.nome) || '') .replace(/\$recipient_name/g, (conv && conv.nome) || ''); } function inserirNoCampo(texto) { var ta = document.querySelector('app-message-text-area textarea'); if (!ta) { avisoAudio('Não encontrei o campo de mensagem.', true); return; } var atual = ta.value || ''; var novo = atual ? atual + (/\s$/.test(atual) ? '' : ' ') + texto : texto; var definir = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; definir.call(ta, novo); ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); try { ta.setSelectionRange(novo.length, novo.length); } catch (e) { /* ignora */ } } function abrirListaRespostas(ancora) { if (listaRespostas) { fecharListaRespostas(); return; } var s = lerSessao(); if (!s) return; var conv = conversaAtual(); estiloRespostas(); var caixa = document.createElement('div'); caixa.id = 'td-canned-lista'; caixa.innerHTML = '
Respostas predefinidas' + '
' + '' + ''; var r = ancora.getBoundingClientRect(); caixa.style.left = Math.max(12, Math.min(r.left, window.innerWidth - 392)) + 'px'; caixa.style.bottom = Math.max(12, window.innerHeight - r.top + 8) + 'px'; document.body.appendChild(caixa); listaRespostas = caixa; document.addEventListener('mousedown', cliqueForaDaLista, true); caixa.querySelector('.tdc-fechar').onclick = fecharListaRespostas; var busca = caixa.querySelector('input'); var ul = caixa.querySelector('ul'); var todas = []; var visiveis = []; var ativo = 0; function escolher(c) { fecharListaRespostas(); inserirNoCampo(trocarMarcadores(c.text, conv)); } function desenhar() { var q = busca.value.trim().toLowerCase(); visiveis = todas.filter(function (c) { return !q || String(c.title || '').toLowerCase().indexOf(q) >= 0 || String(c.text).toLowerCase().indexOf(q) >= 0; }); if (ativo >= visiveis.length) ativo = 0; ul.innerHTML = ''; if (!visiveis.length) { var v = document.createElement('li'); v.className = 'tdc-vazio'; v.style.cursor = 'default'; v.textContent = todas.length ? 'Nenhuma resposta com esse termo.' : 'Nenhuma resposta predefinida cadastrada.'; ul.appendChild(v); return; } visiveis.forEach(function (c, i) { var li = document.createElement('li'); if (i === ativo) li.className = 'tdc-ativo'; var t = document.createElement('div'); t.className = 'tdc-titulo'; t.textContent = c.title || '(sem título)'; var p = document.createElement('div'); p.className = 'tdc-previa'; p.textContent = String(c.text).replace(/\s+/g, ' ').trim(); li.appendChild(t); li.appendChild(p); li.addEventListener('mousedown', function (ev) { ev.preventDefault(); escolher(c); }); ul.appendChild(li); }); } busca.addEventListener('input', function () { ativo = 0; desenhar(); }); busca.addEventListener('keydown', function (ev) { if (ev.key === 'Escape') { ev.preventDefault(); fecharListaRespostas(); return; } if (ev.key === 'ArrowDown' && visiveis.length) { ev.preventDefault(); ativo = (ativo + 1) % visiveis.length; desenhar(); } if (ev.key === 'ArrowUp' && visiveis.length) { ev.preventDefault(); ativo = (ativo - 1 + visiveis.length) % visiveis.length; desenhar(); } if (ev.key === 'Enter') { ev.preventDefault(); if (visiveis[ativo]) escolher(visiveis[ativo]); } }); setTimeout(function () { busca.focus(); }, 0); carregarRespostas(s).then(function (lista) { if (listaRespostas !== caixa) return; todas = lista; desenhar(); }).catch(function (e) { log('falha ao carregar respostas predefinidas', e); if (listaRespostas !== caixa) return; ul.innerHTML = '
  • Não consegui carregar as respostas. Tente de novo.
  • '; }); } function ligarRespostasNoDireto() { if (location.pathname.indexOf('/chat/') !== 0) return; var wpr = document.querySelector('app-message-text-area .canned-responses-btn-wpr'); if (!wpr) { if (listaRespostas) fecharListaRespostas(); return; } var conv = conversaAtual(); var direto = !!(conv && conv.estado === 'active' && conv.id.indexOf('support-group-') !== 0); if (direto) { estiloRespostas(); wpr.classList.add('td-canned-direto'); if (!wpr.__tdCanned) { wpr.__tdCanned = true; wpr.addEventListener('click', function (ev) { if (!wpr.classList.contains('td-canned-direto')) return; ev.preventDefault(); ev.stopPropagation(); abrirListaRespostas(wpr); }, true); } } else if (wpr.classList.contains('td-canned-direto')) { wpr.classList.remove('td-canned-direto'); fecharListaRespostas(); } } // --------------- aviso de mensagem no bate-papo direto (1.8.3) --------------- // O alerta original so olha os atendimentos (/requests). Mensagem de colega no bate-papo // direto (atendente com atendente, supervisor com atendente) e em grupo interno chega pelo // chat21, fora da API de requests - e o chat nao tem notificacao propria (pushEngine // "none"). Aqui o script le a mesma lista de conversas que o chat usa para montar a coluna // da esquerda (GET /chatapi/api/tilechat/{usuario}/conversations, com o token do chat21 // gerado em /api/chat21/native/auth/createCustomToken) e avisa quando a ultima mensagem de // uma conversa fica mais nova que a ultima vista e nao foi o proprio atendente que mandou. // Conversas de cliente (support-group-...) ficam de fora: essas ja sao do verificar(). // Roda so na janela principal do /chat (o painel lateral e um iframe do /dashboard e // tambem carrega o script), e o mapa de vistos fica no localStorage, compartilhado entre // abas - entao uma mensagem nao avisa duas vezes. // // 1.8.6: a mesma lista passou a cuidar tambem da MENSAGEM NOVA DO CLIENTE (conversas // support-group-...). Ela traz, por conversa, o horario e o autor da ultima mensagem, // atualizados a cada mensagem - ao contrario do updatedAt da conversa, que o servidor nao // mexe quando o cliente escreve. Regras: // - conversa que aparece pela primeira vez na lista: so memoriza (quem avisa e o // "Novo atendimento" do verificar()), para nao dar aviso duplo nem enxurrada; // - horario mais novo que o memorizado e autor que nao e o atendente, sistema nem bot: // confere na conversa (GET /requests/{id}) se o autor e mesmo o cliente e avisa; // - nao avisa a conversa que o atendente esta lendo agora, com a janela em foco. // Roda na janela de cima do /chat e do /dashboard (antes so no /chat). var CHAVE_DIRETOS = 'td_alerta_diretos'; var CHAVE_CLIENTES = 'td_alerta_clientes'; var tokenChat21 = null; var tokenChat21Quando = 0; var ultimoOkChat21 = 0; var primeiraRodadaDiretos = (localStorage.getItem(CHAVE_DIRETOS) === null); function chat21EmDia() { // o Chrome segura os timers de aba em segundo plano em ate 1 vez por minuto return Date.now() - ultimoOkChat21 < 90 * 1000; } function lerMapa(chave) { try { return JSON.parse(localStorage.getItem(chave)) || {}; } catch (e) { return {}; } } function gravarMapa(chave, mapa) { try { var ks = Object.keys(mapa); if (ks.length > 300) { ks.sort(function (a, b) { return (mapa[a] || 0) - (mapa[b] || 0); }); ks.slice(0, ks.length - 300).forEach(function (k) { delete mapa[k]; }); } localStorage.setItem(chave, JSON.stringify(mapa)); } catch (e) { /* ignora */ } } function lerDiretos() { return lerMapa(CHAVE_DIRETOS); } function gravarDiretos(mapa) { gravarMapa(CHAVE_DIRETOS, mapa); } function lerClientes() { return lerMapa(CHAVE_CLIENTES); } function gravarClientes(mapa) { gravarMapa(CHAVE_CLIENTES, mapa); } function autorPodeSerCliente(c, s) { var x = c && c.sender; if (!x || x === s.uid || x === 'system' || /^bot_/.test(x)) return false; if (c.attributes && c.attributes.subtype === 'info') return false; return true; } function lendoAgora(id) { if (document.hidden || !document.hasFocus()) return false; var atual = conversaAtual(); if (atual && atual.id === id) return true; var m = location.hash.match(/\/wsrequest\/([^\/?#]+)/); if (!m) return false; var rid = m[1]; try { rid = decodeURIComponent(rid); } catch (e) { /* fica como veio */ } return rid === id; } // confirma pelo atendimento que o autor da ultima mensagem e o cliente; devolve a // conversa (para o texto do aviso) ou null function confirmarCliente(c, s) { var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; return fetch(API + '/' + PROJETO_ID + '/requests/' + encodeURIComponent(c.conversWith), { headers: cab }) .then(function (r) { return r.ok ? r.json() : null; }) .catch(function () { return null; }) .then(function (req) { var lead = req && req.lead && req.lead.lead_id; if (lead) return c.sender === lead ? req : null; // sem o atendimento: agente e bot tem _id de 24 hex; cliente de WhatsApp e wab-... if (/^[0-9a-f]{24}$/i.test(c.sender)) return null; return { request_id: c.conversWith, lead: { fullname: c.sender_fullname || c.recipient_fullname } }; }); } function textoDaConversa(c) { var t = String(c.last_message_text || c.text || '').replace(/\s+/g, ' ').trim(); if (!t && c.type && c.type !== 'text') t = '[anexo]'; return t; } function avisarClientes(candidatos, s) { if (!candidatos.length) return; Promise.all(candidatos.map(function (c) { return confirmarCliente(c, s).then(function (req) { return req ? { c: c, req: req } : null; }); })).then(function (lista) { var ok = lista.filter(Boolean); if (!ok.length) return; if (ok.length > 3) { avisar({ ticket_id: '', lead: { fullname: ok.length + ' clientes' } }, 'mensagem', 'Mensagens novas em ' + ok.length + ' conversas.'); return; } ok.forEach(function (o) { avisar(o.req, 'mensagem', textoDaConversa(o.c)); }); }); } function obterTokenChat21(s, renovar) { if (!renovar && tokenChat21 && Date.now() - tokenChat21Quando < 20 * 60 * 1000) return Promise.resolve(tokenChat21); var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; return fetch(API + '/chat21/native/auth/createCustomToken', { method: 'POST', headers: cab }) .then(function (r) { if (!r.ok) throw new Error('token do chat21: HTTP ' + r.status); return r.json(); }) .then(function (j) { if (!j || !j.token) throw new Error('token do chat21 vazio'); tokenChat21 = j.token; tokenChat21Quando = Date.now(); return tokenChat21; }); } function verificarDiretos(renovando) { if (window.top !== window) return; var s = lerSessao(); if (!s) return; obterTokenChat21(s, renovando === true).then(function (tk) { var cab = {}; cab['Auth' + 'orization'] = tk; return fetch(location.origin + '/chatapi/api/tilechat/' + encodeURIComponent(s.uid) + '/conversations', { headers: cab }); }).then(function (r) { if ((r.status === 401 || r.status === 403) && renovando !== true) { tokenChat21 = null; verificarDiretos(true); return null; } return r.ok ? r.json() : null; }).then(function (j) { if (!j || !Array.isArray(j.result)) return; ultimoOkChat21 = Date.now(); var vistos = lerDiretos(); var clientes = lerClientes(); var novos = []; var candidatos = []; j.result.forEach(function (c) { var id = c && c.conversWith; if (!id) return; var ts = Number(c.timestamp) || 0; if (String(id).indexOf('support-group-') === 0) { var antesC = clientes[id]; if (antesC === undefined) { clientes[id] = ts; return; } // acabou de aparecer if (ts <= antesC) return; clientes[id] = ts; if (autorPodeSerCliente(c, s) && !lendoAgora(id)) candidatos.push(c); return; } var antes = vistos[id]; if (antes === undefined || ts > antes) { vistos[id] = ts; if (c.sender && c.sender !== s.uid && c.sender !== 'system') novos.push(c); } }); gravarDiretos(vistos); gravarClientes(clientes); avisarClientes(candidatos, s); if (primeiraRodadaDiretos) { primeiraRodadaDiretos = false; log('bate-papo direto: ' + j.result.length + ' conversas memorizadas, sem aviso'); return; } if (novos.length > 3) { avisarResumoDiretos(novos.length); return; } novos.forEach(avisarDireto); }).catch(function (e) { log('erro ao consultar o bate-papo direto', e); }); } function avisarDireto(c) { var atual = conversaAtual(); if (atual && atual.id === c.conversWith && document.hasFocus() && !document.hidden) return; // ja esta lendo var quem = c.sender_fullname || 'Colega'; var grupo = c.channel_type === 'group' ? (c.recipient_fullname || 'grupo') : ''; var texto = String(c.last_message_text || c.text || '').replace(/\s+/g, ' ').trim().slice(0, 120); if (!texto && c.type && c.type !== 'text') texto = '[anexo]'; var corpo = texto || 'Nova mensagem no bate-papo direto.'; var titulo = grupo ? (quem + ' em ' + grupo) : ('Mensagem de ' + quem); tocar(); faixaNaPagina(titulo, corpo); piscarTitulo('🔔 Nova mensagem!'); if (!('Notification' in window) || Notification.permission !== 'granted') return; try { var n = new Notification(titulo, { body: corpo, tag: 'td-direto-' + c.conversWith + '-' + c.timestamp, requireInteraction: true, icon: location.origin + '/chat/assets/favicon/favicon-96x96.png' }); n.onclick = function () { try { window.focus(); } catch (e) { /* ignora */ } try { if (location.pathname.indexOf('/chat') === 0) { location.hash = '#/conversation-detail/' + encodeURIComponent(c.conversWith) + '/' + encodeURIComponent(grupo || quem) + '/active'; } } catch (e) { /* ignora */ } n.close(); }; } catch (e) { log('falha ao notificar', e); } } function avisarResumoDiretos(qtd) { var titulo = qtd + ' mensagens novas no bate-papo direto'; tocar(); faixaNaPagina(titulo, 'Confira a lista de conversas à esquerda.'); piscarTitulo('🔔 Nova mensagem!'); if (!('Notification' in window) || Notification.permission !== 'granted') return; try { var n = new Notification(titulo, { body: 'Confira a lista de conversas no chat.', tag: 'td-direto-resumo', requireInteraction: true, icon: location.origin + '/chat/assets/favicon/favicon-96x96.png' }); n.onclick = function () { try { window.focus(); } catch (e) { /* ignora */ } n.close(); }; } catch (e) { log('falha ao notificar', e); } } // ------------- trava da conversa de outro atendente no painel (1.8.5) ------------- // A lista ja vem filtrada (bloco de privacidade no inicio do arquivo), mas o detalhe // #/project/{projeto}/wsrequest/{conversa}/... ainda abre por link direto. Para agente // com a chave ligada, cobre a tela enquanto confere de quem e a conversa; se ela ja tem // atendente e nao e ele, mantem coberta com o aviso e volta para a tela anterior. // Conversa sem atendente (na fila) continua visivel, como antes. var veuPrivacidade = null; var conversaConferida = null; function veu(texto) { if (!veuPrivacidade) { veuPrivacidade = document.createElement('div'); veuPrivacidade.id = 'td-privacidade-veu'; veuPrivacidade.style.cssText = 'position:fixed;inset:0;z-index:2147483645;background:#f8f9fa;' + 'display:flex;align-items:center;justify-content:center;font:15px/1.5 system-ui,Segoe UI,Roboto,sans-serif;' + 'color:#495057;text-align:center;padding:24px'; document.body.appendChild(veuPrivacidade); } veuPrivacidade.textContent = texto; } function tirarVeu() { if (veuPrivacidade) { veuPrivacidade.remove(); veuPrivacidade = null; } } function travarConversaDeOutro() { if (location.pathname.indexOf('/dashboard/') !== 0) return; var m = location.hash.match(/\/wsrequest\/([^\/?#]+)/); if (!m) { conversaConferida = null; tirarVeu(); return; } var rid = m[1]; try { rid = decodeURIComponent(rid); } catch (e) { /* fica como veio */ } if (conversaConferida === rid) return; var priv = window.__tdPrivacidade || (typeof unsafeWindow !== 'undefined' && unsafeWindow.__tdPrivacidade); var uid = priv && priv.uidRestrito(); conversaConferida = rid; if (!uid) { tirarVeu(); return; } var s = lerSessao(); if (!s) return; veu('Conferindo a conversa...'); var cab = {}; cab['Auth' + 'orization'] = s.cabecalho; fetch(API + '/' + PROJETO_ID + '/requests/' + encodeURIComponent(rid), { headers: cab }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (req) { if (conversaConferida !== rid) return; var ag = (req && (req.participantsAgents || req.participants)) || []; if (!ag.length || ag.indexOf(uid) >= 0) { tirarVeu(); return; } veu('Esta conversa é de outro atendente.'); log('conversa de outro atendente bloqueada: ' + rid); setTimeout(function () { if (conversaConferida === rid) history.back(); }, 2500); }) .catch(function () { tirarVeu(); }); } // ------------- tempo real: recuperar o que chegou com a conexao caida (1.8.8) ------------- // Medido em 30/10: com a aba do chat em segundo plano o Chrome segura os timers, o chat nao // consegue mandar o sinal de vida da conexao em tempo real (MQTT, a cada 10 s) e o servidor // derruba a conexao (o broker em 15 s sem sinal; o nginx do /mqws/ em 60 s sem trafego). // Mensagem que chega nesse intervalo se perde (sessao "clean") e, ao reconectar, o chat so // volta a ouvir dali em diante - por isso o atendente precisava recarregar a pagina. // Aqui: (1) ao voltar para a aba (ou a rede voltar), se a conexao caiu, reconecta na hora, // sem esperar o timer do Chrome; (2) depois de toda reconexao, e ao voltar para a aba apos // 20 s fora, busca pela API do chat a lista de conversas e as ultimas mensagens das // conversas ja abertas e acrescenta so o que falta, pelo mesmo caminho que o chat usa ao // abrir a conversa (substitui pelo id, nao duplica). Definitivo, no servidor: nginx do // /mqws/ com proxy_read_timeout maior e o chat buscar o que faltou ao reconectar. var PAG = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; var tr = { cliente: null, caiu: false, pendente: false, ultima: 0, escondidoDesde: document.hidden ? Date.now() : 0 }; function noChatPrincipal() { return location.pathname.indexOf('/chat/') === 0 && window.top === window; } function appDoChat() { try { var raiz = document.querySelector('app-root'); if (!raiz) return null; if (PAG.ng && typeof PAG.ng.getComponent === 'function') { var a = PAG.ng.getComponent(raiz); if (a && a.chatManager) return a; } var ctx = raiz.__ngContext__; if (Array.isArray(ctx)) { for (var i = 0; i < ctx.length; i++) if (ctx[i] && typeof ctx[i] === 'object' && ctx[i].chatManager) return ctx[i]; } } catch (e) { /* ignora */ } return null; } function clienteChat21(app) { try { return app.chatManager.conversationsHandlerService.chat21Service.chatClient; } catch (e) { return null; } } function ressincronizar(motivo) { var app = appDoChat(); var cc = app && clienteChat21(app); if (!cc || typeof cc.lastConversations !== 'function' || typeof cc.lastMessages !== 'function') return; if (Date.now() - tr.ultima < 5000) return; // evita rajada tr.ultima = Date.now(); var convs = app.chatManager.conversationsHandlerService; var abertas = (app.chatManager.handlers || []).filter(function (h) { return h && h.conversationWith && typeof h.addedMessage === 'function'; }); var novasConv = 0, novasMsg = 0, pend = 1 + abertas.length; function fim() { if (--pend === 0) log('tempo real (' + motivo + '): ' + novasConv + ' conversa(s) atualizada(s), ' + novasMsg + ' mensagem(ns) recuperada(s)'); } cc.lastConversations(false, function (err, lista) { if (!err && Array.isArray(lista) && typeof convs.added === 'function') { var atuais = convs.conversations || []; lista.forEach(function (conv) { var cw = conv.conversation_with || conv.conversWith; var ja = null; for (var i = 0; i < atuais.length; i++) if (atuais[i].conversation_with === cw) { ja = atuais[i]; break; } if (ja && Number(ja.timestamp) >= Number(conv.timestamp) && ja.last_message_text === conv.last_message_text) return; conv.sound = false; try { convs.added(conv); novasConv++; } catch (e) { /* ignora */ } }); } fim(); }); abertas.forEach(function (h) { cc.lastMessages(h.conversationWith, function (err, msgs) { if (!err && Array.isArray(msgs)) { var tem = {}; (h.messages || []).forEach(function (m) { if (m && m.uid) tem[m.uid] = 1; }); msgs.sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0); }); msgs.forEach(function (m) { var temp = m.attributes && m.attributes.tempUID; if (tem[m.message_id] || (temp && tem[temp])) return; // ja esta na tela m.uid = m.message_id; try { h.addedMessage(m); novasMsg++; } catch (e) { /* ignora */ } }); } fim(); }); }); } // liga os ouvintes na conexao do chat (de novo se o chat criar outra) function vigiarTempoReal() { if (!noChatPrincipal()) return; var app = appDoChat(); var cc = app && clienteChat21(app); var c = cc && cc.client; if (!c || c === tr.cliente || typeof c.on !== 'function') return; tr.cliente = c; c.on('close', function () { tr.caiu = true; }); c.on('connect', function () { if (tr.caiu || tr.pendente) { tr.caiu = false; tr.pendente = false; setTimeout(function () { ressincronizar('reconexao'); }, 1500); } }); log('tempo real: vigiando a conexao do chat'); } function aoVoltarTempoReal(redeVoltou) { if (!noChatPrincipal()) return; if (document.hidden && !redeVoltou) { tr.escondidoDesde = Date.now(); return; } var fora = tr.escondidoDesde ? Date.now() - tr.escondidoDesde : 0; if (!document.hidden) tr.escondidoDesde = 0; var c = tr.cliente; if (!c) return; if (!c.connected) { tr.pendente = true; // o "connect" que vier ressincroniza try { if (typeof c.reconnect === 'function' && !c.disconnecting) c.reconnect(); } catch (e) { /* o mqtt tenta sozinho */ } } else if (fora >= 20000 || redeVoltou) { ressincronizar(redeVoltou ? 'rede voltou' : 'volta para a aba'); } } // ------------------------------- arranque ------------------------------- log('carregado'); escolherTokenBom(); consertarRolagem(); setTimeout(consertarRolagem, 3000); montarSelo(); setTimeout(montarSelo, 3000); setTimeout(verificar, 4000); setInterval(verificar, INTERVALO_MS); setTimeout(verificarDiretos, 6000); setInterval(verificarDiretos, INTERVALO_MS); travarConversaDeOutro(); window.addEventListener('hashchange', travarConversaDeOutro); setInterval(travarConversaDeOutro, 1000); ligarPausas(); setTimeout(ligarPausas, 3000); carregarAceitosDoProjeto(); setInterval(consertarAnexoBatePapoDireto, 2000); setInterval(montarBotaoAudio, 1000); setInterval(ligarRespostasNoDireto, 1000); setTimeout(conferirVersao, 5000); setInterval(conferirVersao, 30 * 60 * 1000); setInterval(vigiarTempoReal, 2000); document.addEventListener('visibilitychange', function () { aoVoltarTempoReal(false); }); window.addEventListener('focus', function () { aoVoltarTempoReal(false); }); window.addEventListener('online', function () { aoVoltarTempoReal(true); }); } // O restante (alerta, selo, pausas, rolagem) mexe na pagina: espera o DOM existir, // como acontecia quando o script inteiro rodava em document-idle. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', alertaTiledeskPrincipal); } else { alertaTiledeskPrincipal(); }