@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepapo/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "SDK do TelePapo Developers: autorize contas comerciais e receba e responda mensagens com criptografia de ponta a ponta (Signal).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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.3' };
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.3' },
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.3' },
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
+ }