@bravophone/webphone 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +64 -5
  2. package/dist/bravophone.mjs +419 -302
  3. package/dist/bravophone.umd.js +66 -7
  4. package/host/_locales/pt_BR/messages.json +1430 -0
  5. package/host/allowed-origins.json +8 -0
  6. package/host/css/bravophoneContactDialer.css +385 -0
  7. package/host/css/dark-theme.css +116 -0
  8. package/host/favicon.ico +0 -0
  9. package/host/fonts/Audiowide-Regular.ttf +0 -0
  10. package/host/fonts/Seguiemj.ttf +0 -0
  11. package/host/images/answer-gray.png +0 -0
  12. package/host/images/answer.png +0 -0
  13. package/host/images/auto-answer-off.png +0 -0
  14. package/host/images/auto-answer-on.png +0 -0
  15. package/host/images/avatar.png +0 -0
  16. package/host/images/bravophone-logo.svg +39 -0
  17. package/host/images/bravotech_Bravotech_logo.png +0 -0
  18. package/host/images/circle-green.png +0 -0
  19. package/host/images/circle-orange.png +0 -0
  20. package/host/images/circle-red.png +0 -0
  21. package/host/images/dialer-button-down.png +0 -0
  22. package/host/images/dialer-button.png +0 -0
  23. package/host/images/gateways/activecampaign.png +0 -0
  24. package/host/images/gateways/agendor.png +0 -0
  25. package/host/images/gateways/appfacilita.png +0 -0
  26. package/host/images/gateways/call-in.png +0 -0
  27. package/host/images/gateways/click-to-call.png +0 -0
  28. package/host/images/gateways/clickup.png +0 -0
  29. package/host/images/gateways/exactsales.png +0 -0
  30. package/host/images/gateways/go-high-lvl.png +0 -0
  31. package/host/images/gateways/groner.png +0 -0
  32. package/host/images/gateways/hubspot.png +0 -0
  33. package/host/images/gateways/kommo.png +0 -0
  34. package/host/images/gateways/moskit.png +0 -0
  35. package/host/images/gateways/nectar.png +0 -0
  36. package/host/images/gateways/pipedrive.png +0 -0
  37. package/host/images/gateways/pipefy.png +0 -0
  38. package/host/images/gateways/piperun.png +0 -0
  39. package/host/images/gateways/ploomes.png +0 -0
  40. package/host/images/gateways/rdstation-crm.png +0 -0
  41. package/host/images/gateways/salesforce.png +0 -0
  42. package/host/images/gateways/sharpspring.png +0 -0
  43. package/host/images/gateways/sunhub.png +0 -0
  44. package/host/images/gateways/vendfly.png +0 -0
  45. package/host/images/gateways/zendesk.png +0 -0
  46. package/host/images/gateways/zoho.png +0 -0
  47. package/host/images/hangup.png +0 -0
  48. package/host/images/hold.png +0 -0
  49. package/host/images/icons/logo-128-christmas-celebration.png +0 -0
  50. package/host/images/icons/logo-128.png +0 -0
  51. package/host/images/icons/logo-16-christmas-celebration.png +0 -0
  52. package/host/images/icons/logo-16.png +0 -0
  53. package/host/images/icons/logo-24-christmas-celebration.png +0 -0
  54. package/host/images/icons/logo-24.png +0 -0
  55. package/host/images/icons/logo-32-christmas-celebration.png +0 -0
  56. package/host/images/icons/logo-32.png +0 -0
  57. package/host/images/icons/logo-48-christmas-celebration.png +0 -0
  58. package/host/images/icons/logo-48.png +0 -0
  59. package/host/images/loading.gif +0 -0
  60. package/host/images/logo-bravotech-white.png +0 -0
  61. package/host/images/logo-bravotech.png +0 -0
  62. package/host/images/logo-inline-christmas-celebration.png +0 -0
  63. package/host/images/logo-inline.png +0 -0
  64. package/host/images/mic-off.png +0 -0
  65. package/host/images/mic-on.png +0 -0
  66. package/host/images/play.png +0 -0
  67. package/host/images/transfer.png +0 -0
  68. package/host/index.html +34 -0
  69. package/host/js/bravophone-noise-suppressor.js +418 -0
  70. package/host/js/bravophone-route-selector.js +1099 -0
  71. package/host/js/libwebphone.js +20 -0
  72. package/host/js/noise/LICENSE-web-noise-suppressor.txt +21 -0
  73. package/host/js/noise/rnnoise-worklet.js +13 -0
  74. package/host/js/noise/rnnoise.wasm +0 -0
  75. package/host/js/noise/rnnoise_simd.wasm +0 -0
  76. package/host/mock-webphone.js +195 -0
  77. package/host/mock.html +54 -0
  78. package/host/popup.js +23 -0
  79. package/host/shim/chrome-shim.js +287 -0
  80. package/host/shim/guest-bridge.js +353 -0
  81. package/host/shim/messages.js +13 -0
  82. package/host/styles/theme-fixes.css +98 -0
  83. package/package.json +4 -2
  84. package/types/index.d.ts +30 -2
@@ -0,0 +1,1099 @@
1
+ /**
2
+ * BravoPhone > Seletor de origem do número (rotas de saída)
3
+ *
4
+ * Fonte: GET {API_BASE}/api/trunks -> [{ id, nome, dominio, numeroRota, status }]
5
+ * Só entram troncos com status ATIVO. A lista é recarregada a cada 3 minutos;
6
+ * se a atualização falhar, as rotas já carregadas continuam valendo.
7
+ *
8
+ * Mapeamento: nome -> name (exibido) | dominio -> host (tipo da chamada)
9
+ * numeroRota -> prefix (invisível, concatenado no INVITE)
10
+ *
11
+ * Regras:
12
+ * - 0 ou 1 rota -> o seletor NAO e exibido (com 1 rota o prefixo dela continua valendo)
13
+ * - 2+ rotas -> exibe o seletor acima do teclado do discador
14
+ * - host casando com *.wavoip.com -> a chamada e apresentada como WhatsApp
15
+ * qualquer outro host -> telefonia convencional
16
+ * - prefix e INVISIVEL para o usuario; e concatenado no destino na hora do INVITE
17
+ * (ex.: prefix "01*" + 5511973951769 -> to = 01*5511973951769; prefix null -> numero puro)
18
+ * - name e o texto exibido no seletor
19
+ *
20
+ * A concatenacao do prefixo acontece em js/libwebphone.js, no dialpad.call(),
21
+ * que e o unico funil de chamadas de saida (discador, click-to-call e historico
22
+ * todos preenchem o dialpad e disparam webphone-dial-now).
23
+ */
24
+ (function () {
25
+ "use strict";
26
+
27
+ var DEBUG_PREFIX = "BravoPhone > RouteSelector >";
28
+ var STORAGE_KEY = "bravophoneSelectedRoute";
29
+
30
+ /* AMBIENTE: HOMOLOGACAO. Mesma constante usada pelo popup.js (BP_API_BASE);
31
+ para virar producao troque aqui, no popup.js e no host_permissions. */
32
+ var API_BASE = "https://reports-hmg.teambravotech.com";
33
+ var TOKEN_STORAGE_KEY = "bravophoneVxToken";
34
+
35
+ /**
36
+ * Caminho dos troncos. O servidor autentica esta rota pelo header
37
+ * X-Vx-Token — a mesma credencial de /api/ramais. O cookie de sessao do
38
+ * painel so vale same-origin, dentro da propria pagina do report, porque a
39
+ * resposta nao traz Access-Control-Allow-Credentials. Aqui e header, nunca
40
+ * cookie.
41
+ * Para testar outro caminho sem rebuild, gravar em bravophoneTrunksPath:
42
+ * chrome.storage.local.set({ bravophoneTrunksPath: "/api/voxfree/trunks" })
43
+ */
44
+ var TRUNKS_PATH = "/api/trunks";
45
+ var TRUNKS_PATH_STORAGE_KEY = "bravophoneTrunksPath";
46
+
47
+ /**
48
+ * Lista manual de fallback, usada só quando a API falha. Formato aceito: o
49
+ * mesmo da API ({ id, nome, dominio, numeroRota, status }) ou o interno
50
+ * ({ id, name, host, prefix }). Serve para validar UI e prefixo enquanto o
51
+ * endpoint nao esta disponivel:
52
+ * chrome.storage.local.set({ bravophoneRoutesOverride: [ ... ] })
53
+ */
54
+ var OVERRIDE_STORAGE_KEY = "bravophoneRoutesOverride";
55
+
56
+ /** Recarrega a lista de troncos a cada 3 minutos. */
57
+ var REFRESH_INTERVAL_MS = 3 * 60 * 1000;
58
+
59
+ /**
60
+ * A API devolve numeroRota sem o separador ("01"), mas o destino do INVITE usa
61
+ * "01*5511973951769". Entao o "*" e acrescentado aqui quando ja nao vier.
62
+ */
63
+ var PREFIX_SEPARATOR = "*";
64
+
65
+ /**
66
+ * Troncos ativos da API.
67
+ * Resposta: [{ id, nome, dominio, numeroRota, status }]
68
+ * @returns {Promise<Array<{id:string,name:string,host:string,prefix:?string}>>}
69
+ */
70
+ function fetchRoutes() {
71
+ return Promise.all([loadToken(), readStorage(TRUNKS_PATH_STORAGE_KEY)]).then(function (result) {
72
+ var token = result[0];
73
+ var path = result[1] || TRUNKS_PATH;
74
+
75
+ /* Sem token nao adianta pedir. Antes o fetch saia mesmo assim, com
76
+ headers vazio, e o 401 que voltava parecia erro de servidor quando na
77
+ verdade era sessao ausente no cliente. O cookie tambem nao salva: sem
78
+ Access-Control-Allow-Credentials na resposta, credentials:"include"
79
+ nao autentica cross-origin. Quando o login gravar o token,
80
+ watchToken() recarrega as rotas sozinho. */
81
+ if (!token) {
82
+ var semSessao = new Error(
83
+ "sem " + TOKEN_STORAGE_KEY + " no storage — faca login pelo webphone"
84
+ );
85
+ semSessao.status = 0;
86
+ semSessao.semSessao = true;
87
+ throw semSessao;
88
+ }
89
+
90
+ var url = API_BASE + path + (path.indexOf("?") >= 0 ? "&" : "?") + "_=" + Date.now();
91
+ var options = { cache: "no-store", headers: { "X-Vx-Token": token } };
92
+
93
+ return fetch(url, options).then(function (response) {
94
+ if (!response.ok) {
95
+ var reason =
96
+ response.status === 401
97
+ ? "401 com X-Vx-Token enviado — token recusado: logout ja feito ou token de outro ambiente (hmg e producao tem stores separados); refaca o login"
98
+ : response.status === 404
99
+ ? "404 — o caminho " + path + " nao existe neste ambiente"
100
+ : "HTTP " + response.status;
101
+ var error = new Error(reason + " em " + path);
102
+ error.status = response.status;
103
+ throw error;
104
+ }
105
+ return response.json();
106
+ });
107
+ });
108
+ }
109
+
110
+ function readStorage(key) {
111
+ return new Promise(function (resolve) {
112
+ if (!hasStorage()) return resolve(null);
113
+ try {
114
+ chrome.storage.local.get(key, function (data) {
115
+ resolve((data && data[key]) || null);
116
+ });
117
+ } catch (err) {
118
+ resolve(null);
119
+ }
120
+ });
121
+ }
122
+
123
+ function loadToken() {
124
+ return readStorage(TOKEN_STORAGE_KEY);
125
+ }
126
+
127
+ /**
128
+ * Converte um tronco no formato interno; devolve null se inutilizavel.
129
+ * Aceita o formato da API ({ nome, dominio, numeroRota, status }) e o interno
130
+ * ({ name, host, prefix }), usado pela lista manual de fallback.
131
+ */
132
+ function mapTrunk(trunk) {
133
+ if (!trunk || typeof trunk !== "object") return null;
134
+ if (trunk.status != null && String(trunk.status).toUpperCase() !== "ATIVO") return null;
135
+
136
+ var raw = trunk.numeroRota != null ? trunk.numeroRota : trunk.prefix;
137
+ var prefix = null;
138
+ if (raw != null) {
139
+ var value = String(raw).trim();
140
+ if (value && value.toLowerCase() !== "null") {
141
+ prefix =
142
+ value.slice(-PREFIX_SEPARATOR.length) === PREFIX_SEPARATOR
143
+ ? value
144
+ : value + PREFIX_SEPARATOR;
145
+ }
146
+ }
147
+
148
+ return {
149
+ id: trunk.id,
150
+ name: trunk.nome != null ? trunk.nome : trunk.name,
151
+ host: trunk.dominio != null ? trunk.dominio : trunk.host,
152
+ prefix: prefix,
153
+ /* nas rotas wavoip e o id do device — a chave do status do WhatsApp */
154
+ user: trunk.usuario != null ? trunk.usuario : (trunk.user != null ? trunk.user : null)
155
+ };
156
+ }
157
+
158
+ function mapTrunks(payload) {
159
+ var list = Array.isArray(payload) ? payload : payload && Array.isArray(payload.data) ? payload.data : [];
160
+ var out = [];
161
+ for (var i = 0; i < list.length; i++) {
162
+ var route = mapTrunk(list[i]);
163
+ if (route) out.push(route);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Resolve quando a PRIMEIRA carga de rotas termina — com sucesso, com erro ou
170
+ * pela lista manual de fallback. Quem vai discar precisa esperar por isto:
171
+ * enquanto a lista nao chegou, getSelected() devolve null, getPrefix()
172
+ * devolve "" e applyPrefix() vira no-op — a chamada sai sem o prefixo do
173
+ * tronco. E exatamente o que acontecia nas ligacoes pedidas pelas paginas: o
174
+ * background abre o webphone e manda discar 250ms depois, bem antes de
175
+ * /api/trunks responder. Discando dentro do webphone o problema nao aparece
176
+ * porque a essa altura as rotas ja carregaram ha tempo.
177
+ */
178
+ var readyResolve = null;
179
+ var readyPromise = new Promise(function (resolve) { readyResolve = resolve; });
180
+ var alreadyReady = false;
181
+
182
+ function markReady() {
183
+ if (alreadyReady) return;
184
+ alreadyReady = true;
185
+ readyResolve(routes);
186
+ }
187
+
188
+ var routes = [];
189
+ var selectedId = null;
190
+ var listeners = [];
191
+ var isOpen = false;
192
+ var rootEl = null;
193
+ var refreshTimer = null;
194
+ var pendingRefresh = false;
195
+
196
+ /* ----------------------------------------------------------------- rotas */
197
+
198
+ function normalizeRoute(route, index) {
199
+ if (!route || typeof route !== "object") return null;
200
+ var host = typeof route.host === "string" ? route.host.trim() : "";
201
+ var name = typeof route.name === "string" && route.name.trim() ? route.name.trim() : host;
202
+ if (!name) return null;
203
+ return {
204
+ id: route.id != null ? String(route.id) : host + "#" + index,
205
+ name: name,
206
+ host: host,
207
+ prefix: route.prefix == null || route.prefix === "" ? null : String(route.prefix),
208
+ user: route.user == null || route.user === "" ? null : String(route.user)
209
+ };
210
+ }
211
+
212
+ function normalizeRoutes(list) {
213
+ if (!Array.isArray(list)) return [];
214
+ var out = [];
215
+ for (var i = 0; i < list.length; i++) {
216
+ var r = normalizeRoute(list[i], i);
217
+ if (r) out.push(r);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /** host casa com *.wavoip.com (o proprio dominio tambem conta) */
223
+ function isWhatsappHost(host) {
224
+ if (!host) return false;
225
+ var h = String(host).trim().toLowerCase();
226
+ h = h.replace(/^[a-z]+:\/\//, ""); // tira esquema, se vier
227
+ h = h.split("/")[0].split("@").pop().split(":")[0]; // tira caminho, user e porta
228
+ return h === "wavoip.com" || /\.wavoip\.com$/.test(h);
229
+ }
230
+
231
+ function isWhatsappRoute(route) {
232
+ return !!route && isWhatsappHost(route.host);
233
+ }
234
+
235
+ function getSelected() {
236
+ if (!routes.length) return null;
237
+ for (var i = 0; i < routes.length; i++) {
238
+ if (routes[i].id === selectedId) return routes[i];
239
+ }
240
+ return routes[0];
241
+ }
242
+
243
+ function setSelected(id, persist) {
244
+ var previous = selectedId;
245
+ selectedId = id;
246
+ if (persist !== false) storeSelection(id);
247
+ if (previous !== id) notify();
248
+ }
249
+
250
+ function notify() {
251
+ var route = getSelected();
252
+ for (var i = 0; i < listeners.length; i++) {
253
+ try {
254
+ listeners[i](route);
255
+ } catch (err) {
256
+ console.error(DEBUG_PREFIX, "listener error", err);
257
+ }
258
+ }
259
+ }
260
+
261
+ /* --------------------------------------------------------------- storage */
262
+
263
+ function hasStorage() {
264
+ return typeof chrome !== "undefined" && chrome.storage && chrome.storage.local;
265
+ }
266
+
267
+ function loadSelection() {
268
+ return new Promise(function (resolve) {
269
+ if (!hasStorage()) return resolve(null);
270
+ try {
271
+ chrome.storage.local.get(STORAGE_KEY, function (data) {
272
+ resolve((data && data[STORAGE_KEY]) || null);
273
+ });
274
+ } catch (err) {
275
+ resolve(null);
276
+ }
277
+ });
278
+ }
279
+
280
+ function storeSelection(id) {
281
+ if (!hasStorage()) return;
282
+ try {
283
+ var payload = {};
284
+ payload[STORAGE_KEY] = id;
285
+ chrome.storage.local.set(payload);
286
+ } catch (err) {
287
+ /* storage indisponivel: a selecao vale so para esta sessao */
288
+ }
289
+ }
290
+
291
+ /* ------------------------------------------------------------------ i18n */
292
+
293
+ function t(key, fallback) {
294
+ try {
295
+ if (typeof chrome !== "undefined" && chrome.i18n) {
296
+ var msg = chrome.i18n.getMessage(key);
297
+ if (msg) return msg;
298
+ }
299
+ } catch (err) {
300
+ /* ignore */
301
+ }
302
+ return fallback;
303
+ }
304
+
305
+ function routeKindLabel(route) {
306
+ return isWhatsappRoute(route)
307
+ ? t("routeSelectorWhatsapp", "WhatsApp")
308
+ : t("routeSelectorPhone", "Telefonia convencional");
309
+ }
310
+
311
+ /* ------------------------------------------------- status do WhatsApp */
312
+
313
+ /**
314
+ * O tronco wavoip traz `usuario` = id do device. O estado do numero vem de
315
+ * GET {DEVICES_BASE}/{usuario}/whatsapp/all_info, consultado a cada 10s
316
+ * enquanto o popup estiver aberto:
317
+ *
318
+ * result.status "open" -> conectado (bolinha verde)
319
+ * result.status "connecting" -> falta parear o numero (bolinha ambar +
320
+ * link para o QR)
321
+ * qualquer outro, ou erro/404 -> desconectado (bolinha vermelha)
322
+ *
323
+ * Enquanto a primeira resposta nao chega a bolinha fica cinza: "desconectado"
324
+ * so e afirmado depois de o servidor responder, para o chip nao acusar queda
325
+ * que nao existe.
326
+ *
327
+ * devices.wavoip.com precisa estar em host_permissions — sem isso o fetch do
328
+ * popup morre no CORS.
329
+ */
330
+ var DEVICES_BASE = "https://devices.wavoip.com";
331
+ var STATUS_INTERVAL_MS = 10 * 1000;
332
+
333
+ var deviceStatus = {}; /* usuario -> { status, phone, name } */
334
+ var statusTimer = null;
335
+ var statusInFlight = false;
336
+
337
+ function statusUrl(user) {
338
+ return DEVICES_BASE + "/" + encodeURIComponent(user) + "/whatsapp/all_info";
339
+ }
340
+
341
+ function qrUrl(user) {
342
+ return DEVICES_BASE + "/" + encodeURIComponent(user) + "/whatsapp/qr-image";
343
+ }
344
+
345
+ function whatsappUsers() {
346
+ var users = [];
347
+ for (var i = 0; i < routes.length; i++) {
348
+ var route = routes[i];
349
+ if (!isWhatsappRoute(route) || !route.user) continue;
350
+ if (users.indexOf(route.user) < 0) users.push(route.user);
351
+ }
352
+ return users;
353
+ }
354
+
355
+ function fetchDeviceStatus(user) {
356
+ return fetch(statusUrl(user), { cache: "no-store" })
357
+ .then(function (response) {
358
+ if (!response.ok) throw new Error("HTTP " + response.status);
359
+ return response.json();
360
+ })
361
+ .then(function (payload) {
362
+ var result = payload && payload.result;
363
+ return {
364
+ status: (result && result.status) || "unknown",
365
+ phone: (result && result.phone) || null,
366
+ name: (result && result.name) || null
367
+ };
368
+ })
369
+ .catch(function (err) {
370
+ return { status: "error", phone: null, name: null, error: String((err && err.message) || err) };
371
+ });
372
+ }
373
+
374
+ function statusSignature() {
375
+ return Object.keys(deviceStatus)
376
+ .sort()
377
+ .map(function (user) { return user + ":" + deviceStatus[user].status; })
378
+ .join("|");
379
+ }
380
+
381
+ function refreshDeviceStatus() {
382
+ var users = whatsappUsers();
383
+ if (!users.length || statusInFlight) return Promise.resolve();
384
+
385
+ statusInFlight = true;
386
+ var antes = statusSignature();
387
+
388
+ return Promise.all(
389
+ users.map(function (user) {
390
+ return fetchDeviceStatus(user).then(function (info) { deviceStatus[user] = info; });
391
+ })
392
+ ).then(function () {
393
+ statusInFlight = false;
394
+ if (statusSignature() === antes) return;
395
+ console.info(DEBUG_PREFIX, "status dos numeros:", users.map(function (u) {
396
+ return u.slice(0, 8) + "=" + deviceStatus[u].status;
397
+ }));
398
+ /* so redesenha; a lista aberta continua aberta e as bolinhas atualizam */
399
+ if (rootEl) render();
400
+ }).catch(function () {
401
+ statusInFlight = false;
402
+ });
403
+ }
404
+
405
+ function startStatusPolling() {
406
+ if (statusTimer || !whatsappUsers().length) return;
407
+ refreshDeviceStatus();
408
+ statusTimer = setInterval(refreshDeviceStatus, STATUS_INTERVAL_MS);
409
+ }
410
+
411
+ function stopStatusPolling() {
412
+ if (!statusTimer) return;
413
+ clearInterval(statusTimer);
414
+ statusTimer = null;
415
+ }
416
+
417
+ /** null quando a rota nao e whatsapp ou nao tem device; undefined-safe. */
418
+ function statusOf(route) {
419
+ if (!isWhatsappRoute(route) || !route.user) return null;
420
+ return deviceStatus[route.user] || null;
421
+ }
422
+
423
+ function statusKind(info) {
424
+ if (!info) return "unknown"; /* ainda nao respondeu */
425
+ if (info.status === "open") return "on";
426
+ if (info.status === "connecting") return "pending";
427
+ return "off";
428
+ }
429
+
430
+ function statusLabel(info) {
431
+ switch (statusKind(info)) {
432
+ case "on":
433
+ return t("routeSelectorWhatsappOn", "Número conectado") + (info.phone ? " · " + info.phone : "");
434
+ case "pending":
435
+ return t("routeSelectorWhatsappPending", "Aguardando leitura do QR Code");
436
+ case "unknown":
437
+ return t("routeSelectorWhatsappChecking", "Verificando conexão…");
438
+ default:
439
+ return t("routeSelectorWhatsappOff", "Número desconectado");
440
+ }
441
+ }
442
+
443
+ /* ------------------------------------------------------------------- css */
444
+
445
+ var STYLE_ID = "bp-route-selector-style";
446
+ var CSS = [
447
+ // chip compacto, ancorado acima do display do numero
448
+ ".bp-route-selector{position:relative;flex:none;display:flex;justify-content:flex-start;padding-top:2px;margin-bottom:2px;z-index:40}",
449
+ ".bp-route-selector__toggle{max-width:100%;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 3px;",
450
+ "border-radius:999px;background:var(--bp-panel-alt,#1c2131);border:1px solid var(--bp-border,#262c40);",
451
+ "color:var(--bp-text,#e7e9ee);cursor:pointer;text-align:left;line-height:1;",
452
+ "transition:border-color .15s ease,background .15s ease}",
453
+ ".bp-route-selector__toggle:hover{background:var(--bp-border,#262c40);border-color:var(--bp-text-faint,#6b7488)}",
454
+ ".bp-route-selector__toggle:focus{outline:none;border-color:var(--bp-accent,#22c55e)}",
455
+ ".bp-route-selector__icon{position:relative;flex:none;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center}",
456
+ // bolinha de conexao do numero, encostada no canto do icone do whatsapp
457
+ ".bp-route-selector__dot{position:absolute;right:-1px;bottom:-1px;width:8px;height:8px;border-radius:50%;",
458
+ "box-sizing:content-box;border:1.5px solid var(--bp-panel-alt,#1c2131)}",
459
+ ".bp-route-selector__dot--on{background:#22c55e}",
460
+ ".bp-route-selector__dot--pending{background:#f59e0b}",
461
+ ".bp-route-selector__dot--off{background:#ef4444}",
462
+ ".bp-route-selector__dot--unknown{background:var(--bp-text-faint,#6b7488)}",
463
+ ".bp-route-selector__qr{display:inline-block;margin-top:3px;font-size:10px;font-weight:600;",
464
+ "color:var(--bp-accent,#22c55e);text-decoration:underline;cursor:pointer}",
465
+ ".bp-route-selector__qr:hover{opacity:.8}",
466
+ ".bp-route-selector__icon svg{width:13px;height:13px;display:block}",
467
+ ".bp-route-selector__icon--whatsapp{background:#25d366;color:#0b141a}",
468
+ ".bp-route-selector__icon--phone{background:var(--bp-blue,#3b82f6);color:#fff}",
469
+ // no chip os textos ficam em uma linha so; na lista, empilhados
470
+ ".bp-route-selector__texts{min-width:0;display:flex;align-items:baseline;gap:5px;overflow:hidden}",
471
+ ".bp-route-selector__name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
472
+ ".bp-route-selector__kind{font-size:10px;color:var(--bp-text-dim,#9aa2b8);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
473
+ ".bp-route-selector__toggle .bp-route-selector__kind::before{content:'\\00b7';margin-right:5px}",
474
+ ".bp-route-selector__option .bp-route-selector__texts{display:block;flex:1}",
475
+ ".bp-route-selector__option .bp-route-selector__name,",
476
+ ".bp-route-selector__option .bp-route-selector__kind{display:block;line-height:1.3}",
477
+ ".bp-route-selector__chevron{flex:none;width:11px;height:11px;color:var(--bp-text-faint,#6b7488);transition:transform .15s ease}",
478
+ ".bp-route-selector--open .bp-route-selector__chevron{transform:rotate(180deg)}",
479
+ ".bp-route-selector--open .bp-route-selector__toggle{border-color:var(--bp-accent,#22c55e)}",
480
+ ".bp-route-selector__list{position:absolute;left:0;top:100%;margin-top:5px;min-width:230px;max-width:100%;z-index:41;",
481
+ // altura de ~5 opcoes: a 6a fica cortada pela metade, que e o que avisa
482
+ // que a lista rola. overscroll-behavior segura o scroll dentro do dropdown.
483
+ "border-radius:12px;overflow-x:hidden;overflow-y:auto;max-height:230px;overscroll-behavior:contain;",
484
+ "background:var(--bp-panel-alt,#1c2131);border:1px solid var(--bp-border,#262c40);",
485
+ "box-shadow:0 12px 28px -8px rgba(0,0,0,.65)}",
486
+ ".bp-route-selector__list::-webkit-scrollbar{width:8px}",
487
+ ".bp-route-selector__list::-webkit-scrollbar-track{background:transparent}",
488
+ ".bp-route-selector__list::-webkit-scrollbar-thumb{border-radius:999px;border:2px solid transparent;",
489
+ "background:var(--bp-border,#262c40);background-clip:content-box}",
490
+ ".bp-route-selector__list::-webkit-scrollbar-thumb:hover{background:var(--bp-text-faint,#6b7488);background-clip:content-box}",
491
+ ".bp-route-selector__option{width:100%;display:flex;align-items:center;gap:8px;padding:8px 10px;background:transparent;border:0;",
492
+ "color:var(--bp-text,#e7e9ee);cursor:pointer;text-align:left}",
493
+ ".bp-route-selector__option:hover{background:var(--bp-border,#262c40)}",
494
+ ".bp-route-selector__option + .bp-route-selector__option{border-top:1px solid var(--bp-border-soft,#1f2536)}",
495
+ ".bp-route-selector__check{flex:none;width:12px;height:12px;color:var(--bp-accent,#22c55e)}"
496
+ ].join("");
497
+
498
+ function ensureStyle() {
499
+ if (document.getElementById(STYLE_ID)) return;
500
+ var style = document.createElement("style");
501
+ style.id = STYLE_ID;
502
+ style.textContent = CSS;
503
+ (document.head || document.documentElement).appendChild(style);
504
+ }
505
+
506
+ /* -------------------------------------------------------------------- ui */
507
+
508
+ var SVG_NS = "http://www.w3.org/2000/svg";
509
+
510
+ function svg(viewBox, paths, cls) {
511
+ var el = document.createElementNS(SVG_NS, "svg");
512
+ el.setAttribute("viewBox", viewBox);
513
+ el.setAttribute("fill", "currentColor");
514
+ if (cls) el.setAttribute("class", cls);
515
+ for (var i = 0; i < paths.length; i++) {
516
+ var p = document.createElementNS(SVG_NS, "path");
517
+ p.setAttribute("d", paths[i]);
518
+ el.appendChild(p);
519
+ }
520
+ return el;
521
+ }
522
+
523
+ function whatsappIcon() {
524
+ return svg("0 0 24 24", [
525
+ "M12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 1.75.46 3.45 1.32 4.95L2 22l5.25-1.38a9.9 9.9 0 0 0 4.79 1.22h.01c5.46 0 9.91-4.45 9.91-9.91 0-2.65-1.03-5.14-2.9-7.01A9.82 9.82 0 0 0 12.04 2Zm0 1.82c2.16 0 4.19.84 5.72 2.37a8.04 8.04 0 0 1 2.37 5.72c0 4.46-3.63 8.09-8.09 8.09a8.2 8.2 0 0 1-4.18-1.15l-.3-.18-3.11.82.83-3.04-.2-.31a8.13 8.13 0 0 1-1.24-4.23c0-4.46 3.63-8.09 8.2-8.09Zm-2.4 4.1c-.17 0-.45.06-.69.31-.24.25-.9.88-.9 2.15 0 1.27.92 2.5 1.05 2.67.13.17 1.8 2.86 4.46 3.9 2.21.86 2.66.69 3.14.65.48-.04 1.55-.63 1.77-1.24.22-.61.22-1.14.16-1.25-.07-.11-.24-.17-.5-.3-.26-.13-1.55-.77-1.79-.85-.24-.09-.41-.13-.59.13-.17.25-.67.85-.82 1.02-.15.18-.3.2-.56.07-.26-.13-1.1-.41-2.1-1.3-.78-.69-1.3-1.55-1.45-1.81-.15-.26-.02-.4.11-.53.12-.12.26-.3.39-.46.13-.15.17-.26.26-.44.09-.17.04-.33-.02-.46-.07-.13-.58-1.41-.8-1.93-.21-.5-.42-.44-.58-.44l-.5-.01Z"
526
+ ]);
527
+ }
528
+
529
+ function phoneIcon() {
530
+ return svg("0 0 20 20", [
531
+ "M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.5 11.5 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15C7.82 18 2 12.18 2 5V3.5Z"
532
+ ]);
533
+ }
534
+
535
+ function chevronIcon() {
536
+ return svg("0 0 20 20", [
537
+ "M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.17l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z"
538
+ ], "bp-route-selector__chevron");
539
+ }
540
+
541
+ function checkIcon() {
542
+ return svg("0 0 20 20", [
543
+ "M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0l-3.5-3.5a1 1 0 1 1 1.4-1.4l2.8 2.79 6.8-6.79a1 1 0 0 1 1.4 0Z"
544
+ ], "bp-route-selector__check");
545
+ }
546
+
547
+ function buildIconBadge(route) {
548
+ var wrap = document.createElement("span");
549
+ var whats = isWhatsappRoute(route);
550
+ wrap.className =
551
+ "bp-route-selector__icon " +
552
+ (whats ? "bp-route-selector__icon--whatsapp" : "bp-route-selector__icon--phone");
553
+ wrap.appendChild(whats ? whatsappIcon() : phoneIcon());
554
+
555
+ /* tronco wavoip sem `usuario` nao tem device para consultar: sem bolinha,
556
+ em vez de uma bolinha cinza eterna */
557
+ if (whats && route.user) {
558
+ var info = statusOf(route);
559
+ var dot = document.createElement("span");
560
+ dot.className = "bp-route-selector__dot bp-route-selector__dot--" + statusKind(info);
561
+ dot.title = statusLabel(info);
562
+ wrap.appendChild(dot);
563
+ }
564
+
565
+ return wrap;
566
+ }
567
+
568
+ function buildTexts(route) {
569
+ var texts = document.createElement("span");
570
+ texts.className = "bp-route-selector__texts";
571
+
572
+ var name = document.createElement("span");
573
+ name.className = "bp-route-selector__name";
574
+ name.textContent = route.name;
575
+
576
+ var kind = document.createElement("span");
577
+ kind.className = "bp-route-selector__kind";
578
+ kind.textContent = routeKindLabel(route);
579
+
580
+ texts.appendChild(name);
581
+ texts.appendChild(kind);
582
+ return texts;
583
+ }
584
+
585
+ function closeList() {
586
+ if (!isOpen) return;
587
+ isOpen = false;
588
+ if (pendingRefresh) refreshUi(); // atualizacao que chegou com o dropdown aberto
589
+ else render();
590
+ }
591
+
592
+ function onDocumentClick(event) {
593
+ if (!isOpen || !rootEl) return;
594
+ if (!rootEl.contains(event.target)) closeList();
595
+ }
596
+
597
+ function onDocumentKeydown(event) {
598
+ if (isOpen && event.key === "Escape") {
599
+ event.stopPropagation();
600
+ closeList();
601
+ }
602
+ }
603
+
604
+ function render() {
605
+ if (!rootEl) return;
606
+
607
+ var selected = getSelected();
608
+ if (!selected || routes.length < 2) {
609
+ // 0 ou 1 rota: nada de seletor na tela (o prefixo da unica rota continua valendo)
610
+ rootEl.remove();
611
+ rootEl = null;
612
+ isOpen = false;
613
+ return;
614
+ }
615
+
616
+ rootEl.className = "bp-route-selector" + (isOpen ? " bp-route-selector--open" : "");
617
+ rootEl.textContent = "";
618
+
619
+ var toggle = document.createElement("button");
620
+ toggle.type = "button";
621
+ toggle.className = "bp-route-selector__toggle";
622
+ var infoSelecionada = statusOf(selected);
623
+ toggle.title =
624
+ t("routeSelectorLabel", "Origem da chamada") +
625
+ ": " +
626
+ selected.name +
627
+ " · " +
628
+ routeKindLabel(selected) +
629
+ (infoSelecionada || (isWhatsappRoute(selected) && selected.user)
630
+ ? " · " + statusLabel(infoSelecionada)
631
+ : "");
632
+ toggle.setAttribute("aria-haspopup", "listbox");
633
+ toggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
634
+ toggle.appendChild(buildIconBadge(selected));
635
+ toggle.appendChild(buildTexts(selected));
636
+ toggle.appendChild(chevronIcon());
637
+ toggle.addEventListener("click", function (event) {
638
+ event.preventDefault();
639
+ event.stopPropagation();
640
+ isOpen = !isOpen;
641
+ render();
642
+ });
643
+ rootEl.appendChild(toggle);
644
+
645
+ if (!isOpen) return;
646
+
647
+ var list = document.createElement("div");
648
+ list.className = "bp-route-selector__list";
649
+ list.setAttribute("role", "listbox");
650
+
651
+ routes.forEach(function (route) {
652
+ var option = document.createElement("button");
653
+ option.type = "button";
654
+ option.className = "bp-route-selector__option";
655
+ option.setAttribute("role", "option");
656
+ option.setAttribute("aria-selected", route.id === selected.id ? "true" : "false");
657
+ option.appendChild(buildIconBadge(route));
658
+
659
+ var texts = buildTexts(route);
660
+ /* "connecting" = numero ainda nao pareado: o caminho para resolver isso e
661
+ ler o QR, entao o link fica na propria opcao */
662
+ if (statusKind(statusOf(route)) === "pending") {
663
+ var qr = document.createElement("span");
664
+ qr.className = "bp-route-selector__qr";
665
+ qr.textContent = t("routeSelectorLinkNumber", "Vincular número");
666
+ qr.title = qrUrl(route.user);
667
+ qr.addEventListener("click", function (event) {
668
+ event.preventDefault();
669
+ event.stopPropagation();
670
+ window.open(qrUrl(route.user), "_blank", "noopener");
671
+ });
672
+ texts.appendChild(qr);
673
+ }
674
+ option.appendChild(texts);
675
+
676
+ if (route.id === selected.id) option.appendChild(checkIcon());
677
+ option.addEventListener("click", function (event) {
678
+ event.preventDefault();
679
+ event.stopPropagation();
680
+ isOpen = false;
681
+ setSelected(route.id);
682
+ render();
683
+ });
684
+ list.appendChild(option);
685
+ });
686
+
687
+ rootEl.appendChild(list);
688
+ }
689
+
690
+ /* ---------------------------------------------------------------- mount */
691
+
692
+ /**
693
+ * O discador renderiza .display (numero) e depois .keypad, irmaos dentro da
694
+ * mesma raiz. Existe outro elemento .display em outro componente do bundle,
695
+ * entao ancoramos pelo .keypad (unico) e subimos para o .display irmao dele.
696
+ * O chip entra ACIMA do display; sem display, entra acima do proprio keypad.
697
+ */
698
+ function findAnchor() {
699
+ var keypad = document.querySelector("#app .keypad");
700
+ if (!keypad || !keypad.parentNode) return null;
701
+
702
+ var siblings = keypad.parentNode.children;
703
+ for (var i = 0; i < siblings.length; i++) {
704
+ if (siblings[i].classList.contains("display")) return siblings[i];
705
+ }
706
+ return keypad;
707
+ }
708
+
709
+ /**
710
+ * A tela de ligacao (.call-screen) e um overlay absolute inset-0 por cima do
711
+ * discador. O chip nao pode aparecer durante a chamada — nem "furando" o
712
+ * overlay pelo z-index, nem ocupando espaco atras dele.
713
+ */
714
+ function isCallActive() {
715
+ var screen = document.querySelector("#app .call-screen");
716
+ return !!screen && screen.getClientRects().length > 0;
717
+ }
718
+
719
+ function unmount() {
720
+ if (!rootEl) return;
721
+ rootEl.remove();
722
+ rootEl = null;
723
+ isOpen = false;
724
+ }
725
+
726
+ function mount() {
727
+ if (routes.length < 2) return unmount();
728
+ if (isCallActive()) return unmount();
729
+
730
+ var anchor = findAnchor();
731
+ if (!anchor || !anchor.parentNode) return;
732
+ if (rootEl && rootEl.isConnected && rootEl.parentNode === anchor.parentNode) return;
733
+
734
+ ensureStyle();
735
+ if (rootEl) rootEl.remove();
736
+ rootEl = document.createElement("div");
737
+ rootEl.className = "bp-route-selector";
738
+ isOpen = false;
739
+ anchor.parentNode.insertBefore(rootEl, anchor);
740
+ render();
741
+ console.info(DEBUG_PREFIX, "seletor montado", { acima: anchor.className });
742
+ }
743
+
744
+ /**
745
+ * Decide se o chip deve estar na tela agora: some durante a ligacao e volta
746
+ * quando ela termina; reinsere quando o Vue remonta o discador (troca de aba).
747
+ */
748
+ function syncPresence() {
749
+ if (isCallActive()) return unmount();
750
+ if (!rootEl || !rootEl.isConnected) {
751
+ rootEl = null;
752
+ mount();
753
+ }
754
+ }
755
+
756
+ function watchDom() {
757
+ var scheduled = false;
758
+ var observer = new MutationObserver(function () {
759
+ // o cronometro da ligacao dispara mutacoes a cada segundo: agrupa por frame
760
+ if (scheduled) return;
761
+ scheduled = true;
762
+ requestAnimationFrame(function () {
763
+ scheduled = false;
764
+ syncPresence();
765
+ });
766
+ });
767
+ observer.observe(document.documentElement, { childList: true, subtree: true });
768
+ }
769
+
770
+ /* -------------------------------------------------------------- init/api */
771
+
772
+ function routesSignature(list) {
773
+ return list
774
+ .map(function (r) {
775
+ return [r.id, r.name, r.host, r.prefix || "", r.user || ""].join("|");
776
+ })
777
+ .join("//");
778
+ }
779
+
780
+ /**
781
+ * Troca a lista de rotas preservando a selecao atual sempre que o id ainda
782
+ * existir. Se o dropdown estiver aberto, a re-renderizacao espera ele fechar
783
+ * para nao trocar as opcoes debaixo do clique do usuario.
784
+ */
785
+ function applyRoutes(list, preferredId) {
786
+ var next = normalizeRoutes(list);
787
+ var changed = routesSignature(next) !== routesSignature(routes);
788
+ var wanted = preferredId != null ? String(preferredId) : selectedId;
789
+
790
+ routes = next;
791
+
792
+ /* liga/desliga o polling conforme a lista passe a ter (ou deixe de ter)
793
+ tronco wavoip com device */
794
+ if (whatsappUsers().length) startStatusPolling();
795
+ else stopStatusPolling();
796
+
797
+ var previousSelection = selectedId;
798
+ if (!routes.length) {
799
+ selectedId = null;
800
+ } else if (routes.some(function (r) { return r.id === wanted; })) {
801
+ selectedId = wanted;
802
+ } else {
803
+ selectedId = routes[0].id;
804
+ if (previousSelection && previousSelection !== selectedId) {
805
+ console.warn(DEBUG_PREFIX, "rota selecionada saiu da lista; usando", selectedId);
806
+ }
807
+ }
808
+
809
+ if (!changed && selectedId === previousSelection) return routes;
810
+
811
+ if (isOpen) {
812
+ pendingRefresh = true; // aplicado em closeList()
813
+ } else {
814
+ refreshUi();
815
+ }
816
+ if (selectedId !== previousSelection) notify();
817
+ return routes;
818
+ }
819
+
820
+ function refreshUi() {
821
+ pendingRefresh = false;
822
+ if (rootEl) {
823
+ rootEl.remove();
824
+ rootEl = null;
825
+ }
826
+ mount();
827
+ }
828
+
829
+ /** Busca as rotas na API e aplica. Em caso de falha, mantem as rotas atuais. */
830
+ function load(isRefresh) {
831
+ return Promise.all([fetchRoutes(), isRefresh ? Promise.resolve(selectedId) : loadSelection()])
832
+ .then(function (result) {
833
+ var mapped = mapTrunks(result[0]);
834
+ applyRoutes(mapped, result[1]);
835
+
836
+ console.info(DEBUG_PREFIX, isRefresh ? "rotas atualizadas" : "rotas carregadas", {
837
+ total: routes.length,
838
+ seletorVisivel: routes.length > 1,
839
+ selecionada: selectedId
840
+ });
841
+
842
+ return routes;
843
+ })
844
+ .catch(function (err) {
845
+ var motivo = err && err.message ? err.message : err;
846
+ if (err && err.semSessao) {
847
+ console.warn(DEBUG_PREFIX, "rotas nao carregadas:", motivo);
848
+ } else {
849
+ console.error(
850
+ DEBUG_PREFIX,
851
+ isRefresh ? "falha ao atualizar rotas" : "falha ao carregar rotas",
852
+ motivo
853
+ );
854
+ }
855
+ if (routes.length) return routes; // mantem o que ja estava valendo
856
+
857
+ return readStorage(OVERRIDE_STORAGE_KEY).then(function (override) {
858
+ if (!Array.isArray(override) || !override.length) return routes;
859
+ console.warn(
860
+ DEBUG_PREFIX,
861
+ "API indisponivel — usando a lista manual de",
862
+ OVERRIDE_STORAGE_KEY,
863
+ "(" + override.length + " item(ns))"
864
+ );
865
+ applyRoutes(mapTrunks(override));
866
+ return routes;
867
+ });
868
+ });
869
+ }
870
+
871
+ function startAutoRefresh() {
872
+ if (refreshTimer) return;
873
+ refreshTimer = setInterval(function () {
874
+ load(true);
875
+ }, REFRESH_INTERVAL_MS);
876
+ }
877
+
878
+ function stopAutoRefresh() {
879
+ if (!refreshTimer) return;
880
+ clearInterval(refreshTimer);
881
+ refreshTimer = null;
882
+ }
883
+
884
+ var api = {
885
+ /** Todas as rotas disponiveis. */
886
+ getRoutes: function () {
887
+ return routes.slice();
888
+ },
889
+ /** Rota atualmente selecionada (ou null). */
890
+ getSelected: getSelected,
891
+ /**
892
+ * Resolve quando a primeira carga de rotas termina. Espere por ela antes de
893
+ * discar: sem a lista carregada nao ha rota selecionada e o prefixo do
894
+ * tronco nao entra no destino do INVITE.
895
+ */
896
+ whenReady: function () {
897
+ return readyPromise;
898
+ },
899
+ /** Seleciona uma rota pelo id. */
900
+ select: function (id) {
901
+ setSelected(String(id));
902
+ render();
903
+ },
904
+ /** Prefixo da rota selecionada, sempre string ("" quando prefix e null). */
905
+ getPrefix: function () {
906
+ var route = getSelected();
907
+ return route && route.prefix ? route.prefix : "";
908
+ },
909
+ /** Concatena o prefixo da rota selecionada no destino do INVITE. */
910
+ applyPrefix: function (target) {
911
+ var number = target == null ? "" : String(target);
912
+ var prefix = api.getPrefix();
913
+ if (!prefix || !number) return number;
914
+ if (number.indexOf(prefix) === 0) return number; // ja prefixado
915
+ return prefix + number;
916
+ },
917
+ /**
918
+ * Remove o prefixo de tronco de um destino — o inverso de applyPrefix().
919
+ * Testa TODAS as rotas conhecidas, nao so a selecionada: o numero pode ter
920
+ * saido por outro tronco (rediscagem, entrada antiga do historico). O
921
+ * fallback por formato ("01*", "14*") ainda pega registros gravados antes
922
+ * de o vazamento ser corrigido.
923
+ */
924
+ stripPrefix: function (target) {
925
+ var number = target == null ? "" : String(target);
926
+ if (!number) return number;
927
+ for (var i = 0; i < routes.length; i++) {
928
+ var p = routes[i].prefix;
929
+ if (p && number.length > p.length && number.indexOf(p) === 0) {
930
+ return number.slice(p.length);
931
+ }
932
+ }
933
+ var generico = /^\d{1,4}\*(.+)$/.exec(number);
934
+ return generico ? generico[1] : number;
935
+ },
936
+ /** true quando o host da rota casa com *.wavoip.com. */
937
+ isWhatsapp: function (route) {
938
+ return isWhatsappRoute(route || getSelected());
939
+ },
940
+ /** Substitui as rotas em tempo de execucao, no formato interno. */
941
+ setRoutes: function (list) {
942
+ return applyRoutes(list);
943
+ },
944
+ /** Refaz a busca na API agora (o refresh automatico continua no intervalo). */
945
+ reload: function () {
946
+ return load(routes.length > 0);
947
+ },
948
+ /** Liga/desliga o refresh automatico de 3 minutos. */
949
+ startAutoRefresh: startAutoRefresh,
950
+ stopAutoRefresh: stopAutoRefresh,
951
+ /**
952
+ * Diagnostico pelo console: testa varios caminhos com e sem X-Vx-Token,
953
+ * usando /api/voxfree/profile como controle (esse a extensao sabe que
954
+ * funciona). Serve para provar se o token vale para a rota dos troncos.
955
+ */
956
+ diagnose: function () {
957
+ return Promise.all([
958
+ loadToken(),
959
+ readStorage(TRUNKS_PATH_STORAGE_KEY),
960
+ readStorage("bravophoneRamaisUrl")
961
+ ]).then(function (session) {
962
+ var token = session[0];
963
+ var paths = [
964
+ "/api/voxfree/profile", // controle: token valido tem que dar 200
965
+ session[1] || TRUNKS_PATH,
966
+ "/api/voxfree/trunks",
967
+ "/api/voxfree/troncos",
968
+ "/api/troncos"
969
+ ];
970
+ if (session[2]) paths.push(session[2]); // ramaisUrl: 2o controle
971
+
972
+ var probe = function (path, withToken) {
973
+ var url = API_BASE + path + (path.indexOf("?") >= 0 ? "&" : "?") + "_=" + Date.now();
974
+ /* sem credentials: cookie nunca autentica cross-origin aqui (a
975
+ resposta nao traz Access-Control-Allow-Credentials), entao a
976
+ coluna "sem token" mede mesmo a ausencia de credencial */
977
+ return fetch(url, {
978
+ cache: "no-store",
979
+ headers: withToken && token ? { "X-Vx-Token": token } : {}
980
+ })
981
+ .then(function (response) {
982
+ return response.text().then(function (body) {
983
+ return { status: response.status, body: body.slice(0, 90) };
984
+ });
985
+ })
986
+ .catch(function (err) {
987
+ return { status: "rede", body: String(err && err.message) };
988
+ });
989
+ };
990
+
991
+ return Promise.all(
992
+ paths.map(function (path) {
993
+ return Promise.all([probe(path, true), probe(path, false)]).then(function (r) {
994
+ return {
995
+ caminho: path,
996
+ "com X-Vx-Token": r[0].status,
997
+ "sem token": r[1].status,
998
+ resposta: r[0].body
999
+ };
1000
+ });
1001
+ })
1002
+ ).then(function (rows) {
1003
+ console.table(rows);
1004
+ console.log(DEBUG_PREFIX, "estado", {
1005
+ temToken: !!token,
1006
+ rotasCarregadas: routes.length,
1007
+ chipVisivel: routes.length > 1,
1008
+ chipNoDom: !!(rootEl && rootEl.isConnected),
1009
+ discadorNaTela: !!document.querySelector("#app .keypad")
1010
+ });
1011
+ return rows;
1012
+ });
1013
+ });
1014
+ },
1015
+ /** Reinsere o chip no discador (util para depurar pelo console). */
1016
+ remount: function () {
1017
+ if (rootEl) rootEl.remove();
1018
+ rootEl = null;
1019
+ mount();
1020
+ return !!rootEl;
1021
+ },
1022
+ /** Notificado a cada troca de rota. */
1023
+ onChange: function (callback) {
1024
+ if (typeof callback === "function") listeners.push(callback);
1025
+ }
1026
+ };
1027
+
1028
+ window.BravoPhoneRoutes = api;
1029
+
1030
+ document.addEventListener("click", onDocumentClick, true);
1031
+ document.addEventListener("keydown", onDocumentKeydown, true);
1032
+
1033
+ /**
1034
+ * A rota tambem pode ser trocada de fora: o cartao de ligacao das paginas
1035
+ * (content-script-wavoip.js) grava bravophoneSelectedRoute antes de pedir a
1036
+ * discagem. Sem escutar o storage, um popup ja aberto continuaria com a rota
1037
+ * anterior e a chamada sairia pelo tronco errado.
1038
+ */
1039
+ function watchExternalSelection() {
1040
+ if (!hasStorage() || !chrome.storage.onChanged) return;
1041
+ chrome.storage.onChanged.addListener(function (changes, area) {
1042
+ if (area !== "local" || !changes[STORAGE_KEY]) return;
1043
+
1044
+ var id = changes[STORAGE_KEY].newValue;
1045
+ if (id == null) return;
1046
+ id = String(id);
1047
+ if (id === selectedId) return;
1048
+ if (!routes.some(function (r) { return r.id === id; })) return;
1049
+
1050
+ console.info(DEBUG_PREFIX, "rota trocada por fora:", id);
1051
+ setSelected(id, false); /* ja esta gravado; nao regravar */
1052
+ if (isOpen) pendingRefresh = true; // aplicado em closeList()
1053
+ else refreshUi();
1054
+ });
1055
+ }
1056
+
1057
+ /**
1058
+ * O token chega depois: este script roda no parse do popup.html, antes de o
1059
+ * popup.js (defer) montar o Vue e muito antes de o usuario terminar o login.
1060
+ * Sem isto a primeira carga falhava por falta de sessao e as rotas so
1061
+ * apareciam no refresh de 3 minutos.
1062
+ */
1063
+ function watchToken() {
1064
+ if (!hasStorage() || !chrome.storage.onChanged) return;
1065
+ chrome.storage.onChanged.addListener(function (changes, area) {
1066
+ if (area !== "local" || !changes[TOKEN_STORAGE_KEY]) return;
1067
+ if (!changes[TOKEN_STORAGE_KEY].newValue) return; /* logout: nao recarrega */
1068
+ console.info(DEBUG_PREFIX, TOKEN_STORAGE_KEY, "gravado — recarregando as rotas");
1069
+ load(!!routes.length);
1070
+ });
1071
+ }
1072
+
1073
+ function boot() {
1074
+ watchDom();
1075
+ watchExternalSelection();
1076
+ watchToken();
1077
+ load().then(function () {
1078
+ markReady();
1079
+ startAutoRefresh();
1080
+ });
1081
+ /* se /api/trunks pendurar, a discagem nao pode ficar presa esperando: sai
1082
+ sem prefixo, que e melhor do que nao sair */
1083
+ setTimeout(markReady, 8000);
1084
+ // o popup pode ficar minutos aberto sem foco: revalida ao voltar a aparecer
1085
+ document.addEventListener("visibilitychange", function () {
1086
+ if (!document.hidden) load(true);
1087
+ });
1088
+ window.addEventListener("unload", function () {
1089
+ stopAutoRefresh();
1090
+ stopStatusPolling();
1091
+ });
1092
+ }
1093
+
1094
+ if (document.readyState === "loading") {
1095
+ document.addEventListener("DOMContentLoaded", boot);
1096
+ } else {
1097
+ boot();
1098
+ }
1099
+ })();