@telepapo/sdk 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepapo/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.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 } 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.4' };
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,43 @@ 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.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
+
192
230
  // ------------------------------------------------------------ receber
193
231
 
194
232
  /** Busca o que está pendente (alternativa/complemento ao webhook), decifra e confirma. */
@@ -318,14 +356,17 @@ class Conta {
318
356
  /**
319
357
  * Texto (com *negrito*, _itálico_, ~riscado~ e `código`, que o app mostra
320
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).
321
360
  */
322
- enviarTexto(para, texto, { resposta, botoes } = {}) {
361
+ enviarTexto(para, texto, { resposta, botoes, lista } = {}) {
323
362
  if (!texto?.trim()) throw new Error('Texto vazio.');
363
+ if (botoes && lista) throw new Error('Use botoes ou lista, não os dois.');
324
364
  return this.enviar(para, {
325
365
  tipo: 'texto',
326
366
  texto: texto.slice(0, 65000),
327
367
  ...(resposta ? { resposta } : {}),
328
368
  ...(botoes ? { botoes: validarBotoes(botoes) } : {}),
369
+ ...(lista ? { lista: validarLista(lista) } : {}),
329
370
  });
330
371
  }
331
372
 
@@ -364,7 +405,7 @@ class Conta {
364
405
  const token = await this.token();
365
406
  const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${id}/conteudo`, {
366
407
  method: 'PUT',
367
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.3' },
408
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.4' },
368
409
  body: blob,
369
410
  });
370
411
  if (!r.ok) {
@@ -379,7 +420,7 @@ class Conta {
379
420
  async baixarAnexo(anexo) {
380
421
  const token = await this.token();
381
422
  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' },
423
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'telepapo-sdk/0.4' },
383
424
  });
384
425
  if (!r.ok) {
385
426
  const texto = await r.text();
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
+ }