@telepapo/sdk 0.3.0 → 0.5.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 +41 -0
- package/src/index.js +89 -6
- package/src/pedido.js +111 -0
package/package.json
CHANGED
package/src/botoes.js
CHANGED
|
@@ -68,3 +68,44 @@ export function botaoEscolhido(conteudo) {
|
|
|
68
68
|
if (!b || typeof b.id !== 'string' || typeof b.msg !== 'string') return null;
|
|
69
69
|
return { id: b.id.slice(0, 64), msg: b.msg.slice(0, 64) };
|
|
70
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,14 +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, MAX_LEGENDA } from './botoes.js';
|
|
13
|
+
import { validarBotoes, validarLista, MAX_LEGENDA } from './botoes.js';
|
|
14
14
|
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
const { Signal } = require('../signal/telepapo_signal.js');
|
|
17
17
|
|
|
18
18
|
export { ArmazenamentoArquivo, ArmazenamentoMemoria, verificarAssinatura };
|
|
19
19
|
export { cifrarAnexo, decifrarAnexo } from './anexos.js';
|
|
20
|
-
export { validarBotoes, botaoEscolhido, limparTexto } from './botoes.js';
|
|
20
|
+
export { validarBotoes, validarLista, botaoEscolhido, limparTexto } from './botoes.js';
|
|
21
|
+
export { pedidoDe, pedidoAcaoDe, enderecoDe, FORMAS_PAGAMENTO } from './pedido.js';
|
|
21
22
|
|
|
22
23
|
export class ErroTelePapo extends Error {
|
|
23
24
|
constructor(status, corpo) {
|
|
@@ -58,7 +59,7 @@ export class TelePapo {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
async http(metodo, caminho, { corpo, token, basic } = {}) {
|
|
61
|
-
const headers = { 'User-Agent': 'telepapo-sdk/0.
|
|
62
|
+
const headers = { 'User-Agent': 'telepapo-sdk/0.5' };
|
|
62
63
|
if (corpo !== undefined) headers['Content-Type'] = 'application/json';
|
|
63
64
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
64
65
|
if (basic) headers.Authorization = 'Basic ' + Buffer.from(`${this.clientId}:${this.credencial}`).toString('base64');
|
|
@@ -189,6 +190,85 @@ class Conta {
|
|
|
189
190
|
return { id: e.id, nome: e.nome, username: e.username, escopos: e.escopos };
|
|
190
191
|
}
|
|
191
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.5' },
|
|
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
|
+
|
|
230
|
+
// ------------------------------------------------------------ pedidos
|
|
231
|
+
// Permissão `pedidos.gerenciar` (docs/API.md, seção 6.7). O servidor guarda só
|
|
232
|
+
// o registro (de quem, quando, situação e andamento); itens, preços, entrega e
|
|
233
|
+
// pagamento vêm na própria mensagem, cifrados (`pedidoDe(mensagem.conteudo)`).
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Pedidos recebidos, do mais novo para o mais antigo.
|
|
237
|
+
* @param {{situacao?: 'ABERTO'|'CONCLUIDO'|'CANCELADO', cliente?: string, antes?: string, limite?: number}} [filtro]
|
|
238
|
+
* @returns {Promise<{pedidos: Array<{mensagemId: string, cliente: {id: string, nome: string|null, username: string|null, telefone: string}, situacao: string, criadoEm: string, concluidoEm: string|null, acoes: Array<{codigo: string, descricao: string, em: string}>}>, proximo: string|null}>}
|
|
239
|
+
*/
|
|
240
|
+
listarPedidos(filtro = {}) {
|
|
241
|
+
const q = new URLSearchParams(Object.entries(filtro).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]));
|
|
242
|
+
return this.api('GET', `/v1/eu/pedidos${q.size ? `?${q}` : ''}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
pedido(mensagemId) {
|
|
246
|
+
return this.api('GET', `/v1/eu/pedidos/${encodeURIComponent(mensagemId)}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Grava uma resposta no andamento do pedido **e** avisa o cliente na
|
|
251
|
+
* conversa (texto citando o pedido, com `pedidoAcao`). `CONCLUIDO` conclui.
|
|
252
|
+
* Sem `descricao`, vale a das respostas do pedido configuradas no perfil.
|
|
253
|
+
* @param {string} cliente id de quem fez o pedido (`mensagem.de`)
|
|
254
|
+
* @param {string} mensagemId id da mensagem do pedido (`mensagem.conteudo.id`)
|
|
255
|
+
*/
|
|
256
|
+
async atualizarPedido(cliente, mensagemId, codigo, { descricao } = {}) {
|
|
257
|
+
const p = await this.api('POST', `/v1/eu/pedidos/${encodeURIComponent(mensagemId)}/acoes`, { codigo, ...(descricao ? { descricao } : {}) });
|
|
258
|
+
const feita = p.acoes[p.acoes.length - 1];
|
|
259
|
+
await this.enviar(cliente, {
|
|
260
|
+
tipo: 'texto',
|
|
261
|
+
texto: `${codigo === 'CONCLUIDO' ? '✅' : '📦'} ${feita.descricao}`,
|
|
262
|
+
resposta: { id: mensagemId, autorId: cliente, trecho: '🛒 Pedido' },
|
|
263
|
+
pedidoAcao: { pedido: mensagemId, codigo, descricao: feita.descricao },
|
|
264
|
+
});
|
|
265
|
+
return p;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
concluirPedido(cliente, mensagemId) {
|
|
269
|
+
return this.atualizarPedido(cliente, mensagemId, 'CONCLUIDO');
|
|
270
|
+
}
|
|
271
|
+
|
|
192
272
|
// ------------------------------------------------------------ receber
|
|
193
273
|
|
|
194
274
|
/** Busca o que está pendente (alternativa/complemento ao webhook), decifra e confirma. */
|
|
@@ -318,14 +398,17 @@ class Conta {
|
|
|
318
398
|
/**
|
|
319
399
|
* Texto (com *negrito*, _itálico_, ~riscado~ e `código`, que o app mostra
|
|
320
400
|
* formatados). `botoes`: até 3 (só conta comercial; ver validarBotoes).
|
|
401
|
+
* `lista`: menu de até 10 opções atrás de um botão (ver validarLista).
|
|
321
402
|
*/
|
|
322
|
-
enviarTexto(para, texto, { resposta, botoes } = {}) {
|
|
403
|
+
enviarTexto(para, texto, { resposta, botoes, lista } = {}) {
|
|
323
404
|
if (!texto?.trim()) throw new Error('Texto vazio.');
|
|
405
|
+
if (botoes && lista) throw new Error('Use botoes ou lista, não os dois.');
|
|
324
406
|
return this.enviar(para, {
|
|
325
407
|
tipo: 'texto',
|
|
326
408
|
texto: texto.slice(0, 65000),
|
|
327
409
|
...(resposta ? { resposta } : {}),
|
|
328
410
|
...(botoes ? { botoes: validarBotoes(botoes) } : {}),
|
|
411
|
+
...(lista ? { lista: validarLista(lista) } : {}),
|
|
329
412
|
});
|
|
330
413
|
}
|
|
331
414
|
|
|
@@ -364,7 +447,7 @@ class Conta {
|
|
|
364
447
|
const token = await this.token();
|
|
365
448
|
const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${id}/conteudo`, {
|
|
366
449
|
method: 'PUT',
|
|
367
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.
|
|
450
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.5' },
|
|
368
451
|
body: blob,
|
|
369
452
|
});
|
|
370
453
|
if (!r.ok) {
|
|
@@ -379,7 +462,7 @@ class Conta {
|
|
|
379
462
|
async baixarAnexo(anexo) {
|
|
380
463
|
const token = await this.token();
|
|
381
464
|
const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${encodeURIComponent(anexo.id)}`, {
|
|
382
|
-
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'telepapo-sdk/0.
|
|
465
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'telepapo-sdk/0.5' },
|
|
383
466
|
});
|
|
384
467
|
if (!r.ok) {
|
|
385
468
|
const texto = await r.text();
|
package/src/pedido.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pedido do carrinho (docs/MENSAGENS-RICAS.md §6, §8, §9 e §10 do TelePapo):
|
|
3
|
+
* quem conversa com a conta comercial monta o carrinho com itens do catálogo,
|
|
4
|
+
* escolhe entrega e pagamento, e envia.
|
|
5
|
+
*
|
|
6
|
+
* O preço é o que o CLIENTE VIU, montado no aparelho dele: confira código,
|
|
7
|
+
* preço e disponibilidade no seu cadastro antes de confirmar o pedido.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const inteiro = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
|
11
|
+
// Controles e direção do texto (disfarçam texto); tudo vira uma linha.
|
|
12
|
+
const CONTROLES = /[\u0000-\u001F\u007F--]/g;
|
|
13
|
+
const texto = (v, max) => {
|
|
14
|
+
if (typeof v !== 'string') return null;
|
|
15
|
+
const t = v.replace(CONTROLES, ' ').replace(/\s+/g, ' ').trim();
|
|
16
|
+
return t ? t.slice(0, max) : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Formas de pagamento (§10): código → nome. */
|
|
20
|
+
export const FORMAS_PAGAMENTO = {
|
|
21
|
+
PIX: 'Pix',
|
|
22
|
+
DINHEIRO: 'Dinheiro',
|
|
23
|
+
CREDITO: 'Cartão de crédito',
|
|
24
|
+
DEBITO: 'Cartão de débito',
|
|
25
|
+
VALE_REFEICAO: 'Vale-refeição',
|
|
26
|
+
VALE_ALIMENTACAO: 'Vale-alimentação',
|
|
27
|
+
BOLETO: 'Boleto',
|
|
28
|
+
TRANSFERENCIA: 'Transferência bancária',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const UFS = new Set('AC AL AP AM BA CE DF ES GO MA MT MS MG PA PB PR PE PI RJ RN RS RO RR SC SP SE TO'.split(' '));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Endereço (§8) de uma mensagem (`conteudo.endereco`) ou de um pedido
|
|
35
|
+
* (`pedido.entrega`). Inválido: null.
|
|
36
|
+
* @returns {null | {rotulo: string|null, logradouro: string, numero: string, complemento: string|null, bairro: string|null, cidade: string, uf: string, cep: string|null, referencia: string|null}}
|
|
37
|
+
*/
|
|
38
|
+
export function enderecoDe(e) {
|
|
39
|
+
if (!e || typeof e !== 'object') return null;
|
|
40
|
+
const logradouro = texto(e.logradouro, 120);
|
|
41
|
+
const numero = texto(e.numero, 10);
|
|
42
|
+
const cidade = texto(e.cidade, 60);
|
|
43
|
+
const uf = typeof e.uf === 'string' && UFS.has(e.uf) ? e.uf : null;
|
|
44
|
+
const cep = typeof e.cep === 'string' && /^\d{5}-\d{3}$/.test(e.cep) ? e.cep : null;
|
|
45
|
+
if (!logradouro || !numero || !cidade || !uf) return null;
|
|
46
|
+
return {
|
|
47
|
+
rotulo: texto(e.rotulo, 30),
|
|
48
|
+
logradouro, numero,
|
|
49
|
+
complemento: texto(e.complemento, 60),
|
|
50
|
+
bairro: texto(e.bairro, 60),
|
|
51
|
+
cidade, uf, cep,
|
|
52
|
+
referencia: texto(e.referencia, 120),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Forma de pagamento escolhida no pedido (§10), ou null. */
|
|
57
|
+
function pagamentoDe(p) {
|
|
58
|
+
if (!p || typeof p !== 'object' || typeof p.codigo !== 'string' || !/^[A-Z0-9_]{1,30}$/.test(p.codigo)) return null;
|
|
59
|
+
const descricao = FORMAS_PAGAMENTO[p.codigo] ?? texto(p.descricao, 40);
|
|
60
|
+
if (!descricao) return null;
|
|
61
|
+
const troco = p.codigo === 'DINHEIRO' && inteiro(p.trocoParaCentavos, 1, 100_000_000) ? p.trocoParaCentavos : null;
|
|
62
|
+
return { codigo: p.codigo, descricao, trocoParaCentavos: troco };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lê o pedido de uma mensagem recebida (`mensagem.conteudo`). Itens
|
|
67
|
+
* malformados são descartados; sem nenhum item válido, devolve null.
|
|
68
|
+
* @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, entrega: ReturnType<typeof enderecoDe>, pagamento: null | {codigo: string, descricao: string, trocoParaCentavos: number|null} }}
|
|
69
|
+
*/
|
|
70
|
+
export function pedidoDe(conteudo) {
|
|
71
|
+
const p = conteudo?.pedido;
|
|
72
|
+
if (!p || typeof p !== 'object' || !Array.isArray(p.itens)) return null;
|
|
73
|
+
const itens = p.itens.slice(0, 50).flatMap((i) => {
|
|
74
|
+
const descricao = texto(i?.descricao, 80);
|
|
75
|
+
if (!descricao || !inteiro(i.quantidade, 1, 999) || !inteiro(i.precoCentavos, 0, 100_000_000)) return [];
|
|
76
|
+
return [{
|
|
77
|
+
produtoId: texto(i.produtoId, 64),
|
|
78
|
+
codigo: texto(i.codigo, 64),
|
|
79
|
+
descricao,
|
|
80
|
+
quantidade: i.quantidade,
|
|
81
|
+
precoCentavos: i.precoCentavos,
|
|
82
|
+
// Recalculado aqui: não confie no total que veio.
|
|
83
|
+
totalCentavos: i.quantidade * i.precoCentavos,
|
|
84
|
+
}];
|
|
85
|
+
});
|
|
86
|
+
if (!itens.length) return null;
|
|
87
|
+
return {
|
|
88
|
+
loja: texto(p.loja, 64),
|
|
89
|
+
itens,
|
|
90
|
+
totalCentavos: itens.reduce((s, i) => s + i.totalCentavos, 0),
|
|
91
|
+
moeda: 'BRL',
|
|
92
|
+
observacao: texto(p.observacao, 500),
|
|
93
|
+
entrega: enderecoDe(p.entrega),
|
|
94
|
+
pagamento: pagamentoDe(p.pagamento),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Andamento de pedido numa mensagem (§9): a resposta da loja ou o
|
|
100
|
+
* cancelamento do cliente. Confira quem mandou: `CANCELADO` só vale do cliente
|
|
101
|
+
* do pedido; os outros códigos, só da loja.
|
|
102
|
+
* @returns {null | {pedido: string, codigo: string, descricao: string}}
|
|
103
|
+
*/
|
|
104
|
+
export function pedidoAcaoDe(conteudo) {
|
|
105
|
+
const a = conteudo?.pedidoAcao;
|
|
106
|
+
if (!a || typeof a !== 'object') return null;
|
|
107
|
+
const pedido = typeof a.pedido === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(a.pedido) ? a.pedido : null;
|
|
108
|
+
const codigo = typeof a.codigo === 'string' && /^[A-Z0-9_]{1,30}$/.test(a.codigo) ? a.codigo : null;
|
|
109
|
+
const descricao = texto(a.descricao, 40);
|
|
110
|
+
return pedido && codigo && descricao ? { pedido, codigo, descricao } : null;
|
|
111
|
+
}
|