@telepapo/sdk 0.2.0 → 0.4.0
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.
- package/package.json +1 -1
- package/src/botoes.js +111 -0
- package/src/index.js +72 -13
- package/src/pedido.js +41 -0
package/package.json
CHANGED
package/src/botoes.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Botões nas mensagens (só conta comercial; ver docs/MENSAGENS-RICAS.md no
|
|
3
|
+
* repositório do TelePapo). As mesmas regras valem no app Android e no web:
|
|
4
|
+
* quem recebe descarta o botão que não passa nelas.
|
|
5
|
+
*
|
|
6
|
+
* { id, tipo: 'resposta' | 'link' | 'telefone', rotulo, url?, telefone? }
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MAX_BOTOES = 3;
|
|
10
|
+
export const MAX_ROTULO = 25;
|
|
11
|
+
export const MAX_LEGENDA = 1024;
|
|
12
|
+
|
|
13
|
+
// Controles invisíveis (direção do texto e afins) disfarçam texto e links.
|
|
14
|
+
const CONTROLES = /[\u0000-\u0008\u000B-\u001F\u007F--]/g;
|
|
15
|
+
|
|
16
|
+
/** Tira caracteres de controle e de direção (mantém \n e \t). */
|
|
17
|
+
export function limparTexto(texto) {
|
|
18
|
+
return String(texto ?? '').replace(CONTROLES, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function erro(i, motivo) {
|
|
22
|
+
return new Error(`Botão ${i + 1}: ${motivo}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Confere e normaliza os botões de uma mensagem a enviar. Lança com o motivo
|
|
27
|
+
* (erro de quem chama: melhor saber agora do que o cliente não ver o botão).
|
|
28
|
+
* @returns {Array<{id: string, tipo: string, rotulo: string, url?: string, telefone?: string}>}
|
|
29
|
+
*/
|
|
30
|
+
export function validarBotoes(botoes) {
|
|
31
|
+
if (!Array.isArray(botoes) || !botoes.length) throw new Error('Informe de 1 a 3 botões.');
|
|
32
|
+
if (botoes.length > MAX_BOTOES) throw new Error(`No máximo ${MAX_BOTOES} botões por mensagem.`);
|
|
33
|
+
const ids = new Set();
|
|
34
|
+
return botoes.map((b, i) => {
|
|
35
|
+
const id = String(b?.id ?? '').trim();
|
|
36
|
+
if (!id || id.length > 64) throw erro(i, 'id de 1 a 64 caracteres.');
|
|
37
|
+
if (ids.has(id)) throw erro(i, `id "${id}" repetido.`);
|
|
38
|
+
ids.add(id);
|
|
39
|
+
const rotulo = limparTexto(b.rotulo).replace(/\s+/g, ' ').trim();
|
|
40
|
+
if (!rotulo || [...rotulo].length > MAX_ROTULO) throw erro(i, `rótulo de 1 a ${MAX_ROTULO} caracteres.`);
|
|
41
|
+
switch (b.tipo) {
|
|
42
|
+
case 'resposta':
|
|
43
|
+
return { id, tipo: 'resposta', rotulo };
|
|
44
|
+
case 'link': {
|
|
45
|
+
const url = String(b.url ?? '').trim();
|
|
46
|
+
let u;
|
|
47
|
+
try { u = new URL(url); } catch { throw erro(i, 'url inválida.'); }
|
|
48
|
+
if (!['https:', 'http:'].includes(u.protocol) || url.length > 2000) throw erro(i, 'url precisa ser http(s) e ter até 2000 caracteres.');
|
|
49
|
+
return { id, tipo: 'link', rotulo, url };
|
|
50
|
+
}
|
|
51
|
+
case 'telefone': {
|
|
52
|
+
const telefone = String(b.telefone ?? '').replace(/[^\d+]/g, '');
|
|
53
|
+
if (!/^\+\d{8,15}$/.test(telefone)) throw erro(i, 'telefone no formato +5511912345678.');
|
|
54
|
+
return { id, tipo: 'telefone', rotulo, telefone };
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
throw erro(i, 'tipo deve ser resposta, link ou telefone.');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Botão escolhido numa mensagem recebida (`conteudo.botao`), quando o cliente
|
|
64
|
+
* tocou num botão de resposta: `{ id, msg }` (id do botão e da mensagem).
|
|
65
|
+
*/
|
|
66
|
+
export function botaoEscolhido(conteudo) {
|
|
67
|
+
const b = conteudo?.botao;
|
|
68
|
+
if (!b || typeof b.id !== 'string' || typeof b.msg !== 'string') return null;
|
|
69
|
+
return { id: b.id.slice(0, 64), msg: b.msg.slice(0, 64) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const MAX_ITENS_LISTA = 10;
|
|
73
|
+
|
|
74
|
+
const umaLinha = (v) => limparTexto(v).replace(/\s+/g, ' ').trim();
|
|
75
|
+
const tamanho = (s) => [...s].length;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Confere e normaliza uma lista de opções (até 10 itens no total, em seções):
|
|
79
|
+
* { botao: 'Escolher uma opção', secoes: [{ titulo?, itens: [{ id, titulo, descricao? }] }] }
|
|
80
|
+
* O cliente escolhe um item e a resposta chega como a de um botão
|
|
81
|
+
* (`botaoEscolhido(conteudo).id` é o id do item).
|
|
82
|
+
*/
|
|
83
|
+
export function validarLista(lista) {
|
|
84
|
+
const botao = umaLinha(lista?.botao);
|
|
85
|
+
if (!botao || tamanho(botao) > 20) throw new Error('Lista: botao de 1 a 20 caracteres.');
|
|
86
|
+
const secoes = lista.secoes;
|
|
87
|
+
if (!Array.isArray(secoes) || !secoes.length || secoes.length > 10) throw new Error('Lista: de 1 a 10 seções.');
|
|
88
|
+
const ids = new Set();
|
|
89
|
+
let total = 0;
|
|
90
|
+
const saida = secoes.map((s, si) => {
|
|
91
|
+
const titulo = s?.titulo == null ? '' : umaLinha(s.titulo);
|
|
92
|
+
if (tamanho(titulo) > 24) throw new Error(`Seção ${si + 1}: título até 24 caracteres.`);
|
|
93
|
+
if (!Array.isArray(s.itens) || !s.itens.length) throw new Error(`Seção ${si + 1}: pelo menos 1 item.`);
|
|
94
|
+
const itens = s.itens.map((it, ii) => {
|
|
95
|
+
const onde = `Seção ${si + 1}, item ${ii + 1}`;
|
|
96
|
+
const id = String(it?.id ?? '').trim();
|
|
97
|
+
if (!id || id.length > 64) throw new Error(`${onde}: id de 1 a 64 caracteres.`);
|
|
98
|
+
if (ids.has(id)) throw new Error(`${onde}: id "${id}" repetido.`);
|
|
99
|
+
ids.add(id);
|
|
100
|
+
const t = umaLinha(it.titulo);
|
|
101
|
+
if (!t || tamanho(t) > 24) throw new Error(`${onde}: título de 1 a 24 caracteres.`);
|
|
102
|
+
const d = it.descricao == null ? '' : umaLinha(it.descricao);
|
|
103
|
+
if (tamanho(d) > 72) throw new Error(`${onde}: descrição até 72 caracteres.`);
|
|
104
|
+
return { id, titulo: t, ...(d ? { descricao: d } : {}) };
|
|
105
|
+
});
|
|
106
|
+
total += itens.length;
|
|
107
|
+
return { ...(titulo ? { titulo } : {}), itens };
|
|
108
|
+
});
|
|
109
|
+
if (total > MAX_ITENS_LISTA) throw new Error(`Lista: no máximo ${MAX_ITENS_LISTA} itens no total.`);
|
|
110
|
+
return { botao, secoes: saida };
|
|
111
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,12 +10,15 @@ import { ArmazenamentoArquivo, ArmazenamentoMemoria } from './armazenamento.js';
|
|
|
10
10
|
import { verificarAssinatura } from './webhook.js';
|
|
11
11
|
import { categoria, impressao } from './categoria.js';
|
|
12
12
|
import { abrirAnexo, prepararAnexo } from './anexos.js';
|
|
13
|
+
import { validarBotoes, validarLista, MAX_LEGENDA } from './botoes.js';
|
|
13
14
|
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const { Signal } = require('../signal/telepapo_signal.js');
|
|
16
17
|
|
|
17
18
|
export { ArmazenamentoArquivo, ArmazenamentoMemoria, verificarAssinatura };
|
|
18
19
|
export { cifrarAnexo, decifrarAnexo } from './anexos.js';
|
|
20
|
+
export { validarBotoes, validarLista, botaoEscolhido, limparTexto } from './botoes.js';
|
|
21
|
+
export { pedidoDe } from './pedido.js';
|
|
19
22
|
|
|
20
23
|
export class ErroTelePapo extends Error {
|
|
21
24
|
constructor(status, corpo) {
|
|
@@ -56,7 +59,7 @@ export class TelePapo {
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
async http(metodo, caminho, { corpo, token, basic } = {}) {
|
|
59
|
-
const headers = { 'User-Agent': 'telepapo-sdk/0.
|
|
62
|
+
const headers = { 'User-Agent': 'telepapo-sdk/0.4' };
|
|
60
63
|
if (corpo !== undefined) headers['Content-Type'] = 'application/json';
|
|
61
64
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
62
65
|
if (basic) headers.Authorization = 'Basic ' + Buffer.from(`${this.clientId}:${this.credencial}`).toString('base64');
|
|
@@ -187,6 +190,43 @@ class Conta {
|
|
|
187
190
|
return { id: e.id, nome: e.nome, username: e.username, escopos: e.escopos };
|
|
188
191
|
}
|
|
189
192
|
|
|
193
|
+
// ------------------------------------------------------------ catálogo
|
|
194
|
+
// Permissão `catalogo.gerenciar`. O sistema do comércio sincroniza os itens
|
|
195
|
+
// pelo CÓDIGO dele (SKU): salvar de novo o mesmo código atualiza, não duplica.
|
|
196
|
+
|
|
197
|
+
/** Itens do catálogo da conta (com `codigo` e `categoria`). */
|
|
198
|
+
listarCatalogo() {
|
|
199
|
+
return this.api('GET', '/v1/eu/catalogo');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Cria ou atualiza o item com este código.
|
|
204
|
+
* @param {string} codigo letras, números, ponto, hífen ou sublinhado (até 64)
|
|
205
|
+
* @param {{nome: string, descricao?: string, precoCentavos: number, precoPromoCentavos?: number|null, disponivel?: boolean, categoria?: string|null}} item
|
|
206
|
+
*/
|
|
207
|
+
salvarItemDoCatalogo(codigo, item) {
|
|
208
|
+
return this.api('PUT', `/v1/eu/catalogo/codigo/${encodeURIComponent(codigo)}`, item);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
removerItemDoCatalogo(codigo) {
|
|
212
|
+
return this.api('DELETE', `/v1/eu/catalogo/codigo/${encodeURIComponent(codigo)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Foto do item (JPEG, PNG ou WebP, até 2 MB). */
|
|
216
|
+
async trocarFotoDoItem(codigo, imagem, mime) {
|
|
217
|
+
if (!['image/jpeg', 'image/png', 'image/webp'].includes(mime)) throw new Error('Foto: image/jpeg, image/png ou image/webp.');
|
|
218
|
+
const token = await this.token();
|
|
219
|
+
const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/eu/catalogo/codigo/${encodeURIComponent(codigo)}/foto`, {
|
|
220
|
+
method: 'PUT',
|
|
221
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': mime, 'User-Agent': 'telepapo-sdk/0.4' },
|
|
222
|
+
body: imagem,
|
|
223
|
+
});
|
|
224
|
+
if (!r.ok) {
|
|
225
|
+
const t = await r.text();
|
|
226
|
+
throw new ErroTelePapo(r.status, t ? JSON.parse(t) : null);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
190
230
|
// ------------------------------------------------------------ receber
|
|
191
231
|
|
|
192
232
|
/** Busca o que está pendente (alternativa/complemento ao webhook), decifra e confirma. */
|
|
@@ -313,9 +353,21 @@ class Conta {
|
|
|
313
353
|
});
|
|
314
354
|
}
|
|
315
355
|
|
|
316
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Texto (com *negrito*, _itálico_, ~riscado~ e `código`, que o app mostra
|
|
358
|
+
* formatados). `botoes`: até 3 (só conta comercial; ver validarBotoes).
|
|
359
|
+
* `lista`: menu de até 10 opções atrás de um botão (ver validarLista).
|
|
360
|
+
*/
|
|
361
|
+
enviarTexto(para, texto, { resposta, botoes, lista } = {}) {
|
|
317
362
|
if (!texto?.trim()) throw new Error('Texto vazio.');
|
|
318
|
-
|
|
363
|
+
if (botoes && lista) throw new Error('Use botoes ou lista, não os dois.');
|
|
364
|
+
return this.enviar(para, {
|
|
365
|
+
tipo: 'texto',
|
|
366
|
+
texto: texto.slice(0, 65000),
|
|
367
|
+
...(resposta ? { resposta } : {}),
|
|
368
|
+
...(botoes ? { botoes: validarBotoes(botoes) } : {}),
|
|
369
|
+
...(lista ? { lista: validarLista(lista) } : {}),
|
|
370
|
+
});
|
|
319
371
|
}
|
|
320
372
|
|
|
321
373
|
/** Avisa quem escreveu que a conta leu (e o celular do dono também). */
|
|
@@ -344,7 +396,7 @@ class Conta {
|
|
|
344
396
|
/**
|
|
345
397
|
* Cifra e sobe um arquivo; devolve o `anexo` para pôr numa mensagem `midia`.
|
|
346
398
|
* @param {Buffer|Uint8Array} dados
|
|
347
|
-
* @param {{mime: string, nome?: string, largura?: number, altura?: number, duracaoMs?: number, miniatura?: string}} info
|
|
399
|
+
* @param {{mime: string, nome?: string, largura?: number, altura?: number, duracaoMs?: number, onda?: string, miniatura?: string}} info
|
|
348
400
|
*/
|
|
349
401
|
async subirAnexo(dados, info = {}) {
|
|
350
402
|
if (!info.mime) throw new Error('Informe o mime do anexo.');
|
|
@@ -353,14 +405,14 @@ class Conta {
|
|
|
353
405
|
const token = await this.token();
|
|
354
406
|
const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${id}/conteudo`, {
|
|
355
407
|
method: 'PUT',
|
|
356
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.
|
|
408
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.4' },
|
|
357
409
|
body: blob,
|
|
358
410
|
});
|
|
359
411
|
if (!r.ok) {
|
|
360
412
|
const texto = await r.text();
|
|
361
413
|
throw new ErroTelePapo(r.status, texto ? JSON.parse(texto) : null);
|
|
362
414
|
}
|
|
363
|
-
const extra = Object.fromEntries(['nome', 'largura', 'altura', 'duracaoMs', 'miniatura'].filter((k) => info[k] != null).map((k) => [k, info[k]]));
|
|
415
|
+
const extra = Object.fromEntries(['nome', 'largura', 'altura', 'duracaoMs', 'onda', 'miniatura'].filter((k) => info[k] != null).map((k) => [k, info[k]]));
|
|
364
416
|
return { id, chave, digest, tamanho: Buffer.byteLength(Buffer.from(dados)), mime: info.mime, ...extra };
|
|
365
417
|
}
|
|
366
418
|
|
|
@@ -368,7 +420,7 @@ class Conta {
|
|
|
368
420
|
async baixarAnexo(anexo) {
|
|
369
421
|
const token = await this.token();
|
|
370
422
|
const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${encodeURIComponent(anexo.id)}`, {
|
|
371
|
-
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'telepapo-sdk/0.
|
|
423
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'telepapo-sdk/0.4' },
|
|
372
424
|
});
|
|
373
425
|
if (!r.ok) {
|
|
374
426
|
const texto = await r.text();
|
|
@@ -378,13 +430,20 @@ class Conta {
|
|
|
378
430
|
}
|
|
379
431
|
|
|
380
432
|
/**
|
|
381
|
-
* Envia um arquivo (foto, vídeo, áudio, documento).
|
|
382
|
-
*
|
|
433
|
+
* Envia um arquivo (foto, vídeo, áudio, documento). `legenda` vai na própria
|
|
434
|
+
* mídia (até 1024 caracteres; o app mostra abaixo da imagem/vídeo/documento).
|
|
435
|
+
* Áudio: `duracaoMs` e `onda` (base64 de até 64 barras 0-255) desenham o player.
|
|
383
436
|
*/
|
|
384
|
-
async enviarArquivo(para, dados, { mime, nome, largura, altura, duracaoMs, miniatura, legenda, resposta } = {}) {
|
|
385
|
-
const anexo = await this.subirAnexo(dados, { mime, nome, largura, altura, duracaoMs, miniatura });
|
|
386
|
-
const
|
|
387
|
-
|
|
437
|
+
async enviarArquivo(para, dados, { mime, nome, largura, altura, duracaoMs, onda, miniatura, legenda, resposta, botoes } = {}) {
|
|
438
|
+
const anexo = await this.subirAnexo(dados, { mime, nome, largura, altura, duracaoMs, onda, miniatura });
|
|
439
|
+
const texto = legenda?.trim() ? legenda.trim().slice(0, MAX_LEGENDA) : null;
|
|
440
|
+
const r = await this.enviar(para, {
|
|
441
|
+
tipo: 'midia',
|
|
442
|
+
anexo,
|
|
443
|
+
...(texto ? { texto } : {}),
|
|
444
|
+
...(resposta ? { resposta } : {}),
|
|
445
|
+
...(botoes ? { botoes: validarBotoes(botoes) } : {}),
|
|
446
|
+
});
|
|
388
447
|
return { ...r, anexo };
|
|
389
448
|
}
|
|
390
449
|
|
package/src/pedido.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pedido do carrinho (docs/MENSAGENS-RICAS.md §6 do TelePapo): quem conversa
|
|
3
|
+
* com a conta comercial monta o carrinho com itens do catálogo e envia.
|
|
4
|
+
*
|
|
5
|
+
* O preço é o que o CLIENTE VIU, montado no aparelho dele: confira código,
|
|
6
|
+
* preço e disponibilidade no seu cadastro antes de confirmar o pedido.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const inteiro = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
|
10
|
+
const texto = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lê o pedido de uma mensagem recebida (`mensagem.conteudo`). Itens
|
|
14
|
+
* malformados são descartados; sem nenhum item válido, devolve null.
|
|
15
|
+
* @returns {null | { loja: string|null, itens: Array<{produtoId: string|null, codigo: string|null, descricao: string, quantidade: number, precoCentavos: number, totalCentavos: number}>, totalCentavos: number, moeda: string, observacao: string|null }}
|
|
16
|
+
*/
|
|
17
|
+
export function pedidoDe(conteudo) {
|
|
18
|
+
const p = conteudo?.pedido;
|
|
19
|
+
if (!p || typeof p !== 'object' || !Array.isArray(p.itens)) return null;
|
|
20
|
+
const itens = p.itens.slice(0, 50).flatMap((i) => {
|
|
21
|
+
const descricao = texto(i?.descricao, 80);
|
|
22
|
+
if (!descricao || !inteiro(i.quantidade, 1, 999) || !inteiro(i.precoCentavos, 0, 100_000_000)) return [];
|
|
23
|
+
return [{
|
|
24
|
+
produtoId: texto(i.produtoId, 64),
|
|
25
|
+
codigo: texto(i.codigo, 64),
|
|
26
|
+
descricao,
|
|
27
|
+
quantidade: i.quantidade,
|
|
28
|
+
precoCentavos: i.precoCentavos,
|
|
29
|
+
// Recalculado aqui: não confie no total que veio.
|
|
30
|
+
totalCentavos: i.quantidade * i.precoCentavos,
|
|
31
|
+
}];
|
|
32
|
+
});
|
|
33
|
+
if (!itens.length) return null;
|
|
34
|
+
return {
|
|
35
|
+
loja: texto(p.loja, 64),
|
|
36
|
+
itens,
|
|
37
|
+
totalCentavos: itens.reduce((s, i) => s + i.totalCentavos, 0),
|
|
38
|
+
moeda: 'BRL',
|
|
39
|
+
observacao: texto(p.observacao, 500),
|
|
40
|
+
};
|
|
41
|
+
}
|