@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepapo/sdk",
3
- "version": "0.2.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 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.2' };
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
- enviarTexto(para, texto, { resposta } = {}) {
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
- return this.enviar(para, { tipo: 'texto', texto: texto.slice(0, 65000), ...(resposta ? { resposta } : {}) });
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.2' },
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.2' },
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). A mensagem de mídia do
382
- * TelePapo não tem legenda: `legenda`, se houver, vai logo depois como texto.
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 r = await this.enviar(para, { tipo: 'midia', anexo, ...(resposta ? { resposta } : {}) });
387
- if (legenda?.trim()) await this.enviarTexto(para, legenda);
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
+ }