@telepapo/sdk 0.2.0 → 0.3.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.3.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,70 @@
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
+ }
package/src/index.js CHANGED
@@ -10,12 +10,14 @@ 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
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, botaoEscolhido, limparTexto } from './botoes.js';
19
21
 
20
22
  export class ErroTelePapo extends Error {
21
23
  constructor(status, corpo) {
@@ -56,7 +58,7 @@ export class TelePapo {
56
58
  }
57
59
 
58
60
  async http(metodo, caminho, { corpo, token, basic } = {}) {
59
- const headers = { 'User-Agent': 'telepapo-sdk/0.2' };
61
+ const headers = { 'User-Agent': 'telepapo-sdk/0.3' };
60
62
  if (corpo !== undefined) headers['Content-Type'] = 'application/json';
61
63
  if (token) headers.Authorization = `Bearer ${token}`;
62
64
  if (basic) headers.Authorization = 'Basic ' + Buffer.from(`${this.clientId}:${this.credencial}`).toString('base64');
@@ -313,9 +315,18 @@ class Conta {
313
315
  });
314
316
  }
315
317
 
316
- enviarTexto(para, texto, { resposta } = {}) {
318
+ /**
319
+ * Texto (com *negrito*, _itálico_, ~riscado~ e `código`, que o app mostra
320
+ * formatados). `botoes`: até 3 (só conta comercial; ver validarBotoes).
321
+ */
322
+ enviarTexto(para, texto, { resposta, botoes } = {}) {
317
323
  if (!texto?.trim()) throw new Error('Texto vazio.');
318
- return this.enviar(para, { tipo: 'texto', texto: texto.slice(0, 65000), ...(resposta ? { resposta } : {}) });
324
+ return this.enviar(para, {
325
+ tipo: 'texto',
326
+ texto: texto.slice(0, 65000),
327
+ ...(resposta ? { resposta } : {}),
328
+ ...(botoes ? { botoes: validarBotoes(botoes) } : {}),
329
+ });
319
330
  }
320
331
 
321
332
  /** Avisa quem escreveu que a conta leu (e o celular do dono também). */
@@ -344,7 +355,7 @@ class Conta {
344
355
  /**
345
356
  * Cifra e sobe um arquivo; devolve o `anexo` para pôr numa mensagem `midia`.
346
357
  * @param {Buffer|Uint8Array} dados
347
- * @param {{mime: string, nome?: string, largura?: number, altura?: number, duracaoMs?: number, miniatura?: string}} info
358
+ * @param {{mime: string, nome?: string, largura?: number, altura?: number, duracaoMs?: number, onda?: string, miniatura?: string}} info
348
359
  */
349
360
  async subirAnexo(dados, info = {}) {
350
361
  if (!info.mime) throw new Error('Informe o mime do anexo.');
@@ -353,14 +364,14 @@ class Conta {
353
364
  const token = await this.token();
354
365
  const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${id}/conteudo`, {
355
366
  method: 'PUT',
356
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.2' },
367
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.3' },
357
368
  body: blob,
358
369
  });
359
370
  if (!r.ok) {
360
371
  const texto = await r.text();
361
372
  throw new ErroTelePapo(r.status, texto ? JSON.parse(texto) : null);
362
373
  }
363
- const extra = Object.fromEntries(['nome', 'largura', 'altura', 'duracaoMs', 'miniatura'].filter((k) => info[k] != null).map((k) => [k, info[k]]));
374
+ const extra = Object.fromEntries(['nome', 'largura', 'altura', 'duracaoMs', 'onda', 'miniatura'].filter((k) => info[k] != null).map((k) => [k, info[k]]));
364
375
  return { id, chave, digest, tamanho: Buffer.byteLength(Buffer.from(dados)), mime: info.mime, ...extra };
365
376
  }
366
377
 
@@ -368,7 +379,7 @@ class Conta {
368
379
  async baixarAnexo(anexo) {
369
380
  const token = await this.token();
370
381
  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' },
382
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'telepapo-sdk/0.3' },
372
383
  });
373
384
  if (!r.ok) {
374
385
  const texto = await r.text();
@@ -378,13 +389,20 @@ class Conta {
378
389
  }
379
390
 
380
391
  /**
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.
392
+ * Envia um arquivo (foto, vídeo, áudio, documento). `legenda` vai na própria
393
+ * mídia (até 1024 caracteres; o app mostra abaixo da imagem/vídeo/documento).
394
+ * Áudio: `duracaoMs` e `onda` (base64 de até 64 barras 0-255) desenham o player.
383
395
  */
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);
396
+ async enviarArquivo(para, dados, { mime, nome, largura, altura, duracaoMs, onda, miniatura, legenda, resposta, botoes } = {}) {
397
+ const anexo = await this.subirAnexo(dados, { mime, nome, largura, altura, duracaoMs, onda, miniatura });
398
+ const texto = legenda?.trim() ? legenda.trim().slice(0, MAX_LEGENDA) : null;
399
+ const r = await this.enviar(para, {
400
+ tipo: 'midia',
401
+ anexo,
402
+ ...(texto ? { texto } : {}),
403
+ ...(resposta ? { resposta } : {}),
404
+ ...(botoes ? { botoes: validarBotoes(botoes) } : {}),
405
+ });
388
406
  return { ...r, anexo };
389
407
  }
390
408