@telepapo/sdk 0.1.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/README.md CHANGED
@@ -27,8 +27,12 @@ if (evento.mensagem?.tipo === 'texto') await conta.enviarTexto(evento.mensagem.d
27
27
 
28
28
  API: `iniciarAutorizacao`, `concluirAutorizacao`, `conta(id)`,
29
29
  `processarWebhook`, `verificarAssinatura`; na conta: `receber`, `enviar`,
30
- `enviarTexto`, `marcarLida`, `reagir`, `garantirPreKeys`, `dados`,
31
- `desconectar`.
30
+ `enviarTexto`, `enviarArquivo`, `subirAnexo`, `baixarAnexo`, `marcarLida`,
31
+ `reagir`, `digitando`, `garantirPreKeys`, `dados`, `desconectar`.
32
+
33
+ Mais de um processo usando as mesmas contas (API + worker, réplicas)? Passe
34
+ `travar: (chave, f) => …` (ex.: um lock no Redis): o estado do Signal e o
35
+ refresh token não aguentam dois escritores ao mesmo tempo.
32
36
 
33
37
  O Signal roda em WebAssembly (`signal/`, compilado do libsignal 0.105.4 —
34
38
  o mesmo do app, testado contra a biblioteca oficial).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepapo/sdk",
3
- "version": "0.1.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/anexos.js ADDED
@@ -0,0 +1,38 @@
1
+ // Anexos no mesmo esquema do app e do TelePapo Web (o do Signal):
2
+ // AES-256-CBC + HMAC-SHA256. A chave (64 bytes: 32 de AES, 32 de HMAC) e o
3
+ // digest viajam só dentro da mensagem cifrada; o servidor guarda
4
+ // `iv | cifrado | mac` e nada mais.
5
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
6
+
7
+ export function cifrarAnexo(dado, chave) {
8
+ const iv = randomBytes(16);
9
+ const c = createCipheriv('aes-256-cbc', chave.subarray(0, 32), iv);
10
+ const corpo = Buffer.concat([iv, c.update(dado), c.final()]);
11
+ const mac = createHmac('sha256', chave.subarray(32, 64)).update(corpo).digest();
12
+ return Buffer.concat([corpo, mac]);
13
+ }
14
+
15
+ export function decifrarAnexo(blob, chave) {
16
+ if (blob.length < 16 + 16 + 32) throw new Error('Anexo curto demais.');
17
+ const corpo = blob.subarray(0, blob.length - 32);
18
+ // MAC conferido ANTES de decifrar: CBC sem MAC aceita blob adulterado.
19
+ const mac = createHmac('sha256', chave.subarray(32, 64)).update(corpo).digest();
20
+ if (!timingSafeEqual(mac, blob.subarray(blob.length - 32))) throw new Error('Anexo adulterado (MAC).');
21
+ const d = createDecipheriv('aes-256-cbc', chave.subarray(0, 32), corpo.subarray(0, 16));
22
+ return Buffer.concat([d.update(corpo.subarray(16)), d.final()]);
23
+ }
24
+
25
+ /** Cifra e devolve o blob e o que vai na mensagem ({chave, digest} em base64). */
26
+ export function prepararAnexo(dado) {
27
+ const chave = randomBytes(64);
28
+ const blob = cifrarAnexo(Buffer.from(dado), chave);
29
+ return { blob, chave: chave.toString('base64'), digest: createHash('sha256').update(blob).digest('base64') };
30
+ }
31
+
32
+ /** Confere o digest (vindo da mensagem cifrada) e decifra. */
33
+ export function abrirAnexo(blob, anexo) {
34
+ const digest = createHash('sha256').update(blob).digest();
35
+ const esperado = Buffer.from(anexo.digest ?? '', 'base64');
36
+ if (digest.length !== esperado.length || !timingSafeEqual(digest, esperado)) throw new Error('Anexo adulterado (digest).');
37
+ return decifrarAnexo(blob, Buffer.from(anexo.chave, 'base64'));
38
+ }
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
@@ -9,11 +9,15 @@ import { randomUUID } from 'node:crypto';
9
9
  import { ArmazenamentoArquivo, ArmazenamentoMemoria } from './armazenamento.js';
10
10
  import { verificarAssinatura } from './webhook.js';
11
11
  import { categoria, impressao } from './categoria.js';
12
+ import { abrirAnexo, prepararAnexo } from './anexos.js';
13
+ import { validarBotoes, MAX_LEGENDA } from './botoes.js';
12
14
 
13
15
  const require = createRequire(import.meta.url);
14
16
  const { Signal } = require('../signal/telepapo_signal.js');
15
17
 
16
18
  export { ArmazenamentoArquivo, ArmazenamentoMemoria, verificarAssinatura };
19
+ export { cifrarAnexo, decifrarAnexo } from './anexos.js';
20
+ export { validarBotoes, botaoEscolhido, limparTexto } from './botoes.js';
17
21
 
18
22
  export class ErroTelePapo extends Error {
19
23
  constructor(status, corpo) {
@@ -36,8 +40,12 @@ export class TelePapo {
36
40
  * @param {string} [o.chaveWebhook] chave de assinatura do webhook (portal)
37
41
  * @param {string} [o.apiUrl] padrão https://api.telepapo.com.br
38
42
  * @param {object} [o.armazenamento] { ler, gravar, apagar } — padrão: memória
43
+ * @param {(chave: string, f: () => Promise<any>) => Promise<any>} [o.travar]
44
+ * Trava ENTRE processos (ex.: Redis). Obrigatória se mais de um processo usa
45
+ * a mesma conta: o estado do Signal e o refresh token não aguentam dois
46
+ * escritores ao mesmo tempo. Sem ela, só a fila deste processo.
39
47
  */
40
- constructor({ clientId, credencial, chaveWebhook, apiUrl = 'https://api.telepapo.com.br', armazenamento = new ArmazenamentoMemoria(), fetch = globalThis.fetch }) {
48
+ constructor({ clientId, credencial, chaveWebhook, apiUrl = 'https://api.telepapo.com.br', armazenamento = new ArmazenamentoMemoria(), fetch = globalThis.fetch, travar = null }) {
41
49
  if (!clientId || !credencial) throw new Error('clientId e credencial são obrigatórios.');
42
50
  this.clientId = clientId;
43
51
  this.credencial = credencial;
@@ -45,11 +53,12 @@ export class TelePapo {
45
53
  this.apiUrl = apiUrl.replace(/\/$/, '');
46
54
  this.armazenamento = armazenamento;
47
55
  this.fetch = fetch;
56
+ this.travar = travar;
48
57
  this.contas = new Map();
49
58
  }
50
59
 
51
60
  async http(metodo, caminho, { corpo, token, basic } = {}) {
52
- const headers = { 'User-Agent': 'telepapo-sdk/0.1' };
61
+ const headers = { 'User-Agent': 'telepapo-sdk/0.3' };
53
62
  if (corpo !== undefined) headers['Content-Type'] = 'application/json';
54
63
  if (token) headers.Authorization = `Bearer ${token}`;
55
64
  if (basic) headers.Authorization = 'Basic ' + Buffer.from(`${this.clientId}:${this.credencial}`).toString('base64');
@@ -128,7 +137,8 @@ class Conta {
128
137
 
129
138
  /** Uma operação por vez por conta (o estado do Signal não pode ser usado por duas ao mesmo tempo). */
130
139
  trava(f) {
131
- const vez = this.fila.then(f);
140
+ const externa = this.cliente.travar;
141
+ const vez = this.fila.then(() => (externa ? externa(`telepapo:conta:${this.id}`, f) : f()));
132
142
  this.fila = vez.catch(() => undefined);
133
143
  return vez;
134
144
  }
@@ -154,12 +164,20 @@ class Conta {
154
164
  }
155
165
 
156
166
  async token() {
167
+ const valido = (e) => new Date(e.tokens.acessoExpiraEm).getTime() - Date.now() > 60_000;
157
168
  const e = await this.estado();
158
- if (new Date(e.tokens.acessoExpiraEm).getTime() - Date.now() > 60_000) return e.tokens.acesso;
159
- const par = await this.cliente.http('POST', '/v1/auth/renovar', { corpo: { refresh: e.tokens.refresh } });
160
- e.tokens = { acesso: par.acesso, refresh: par.refresh, acessoExpiraEm: par.acessoExpiraEm };
161
- await this.gravar(e);
162
- return par.acesso;
169
+ if (valido(e)) return e.tokens.acesso;
170
+ const renovar = async () => {
171
+ // Relê dentro da trava: outro processo pode ter acabado de renovar.
172
+ const atual = await this.estado();
173
+ if (valido(atual)) return atual.tokens.acesso;
174
+ const par = await this.cliente.http('POST', '/v1/auth/renovar', { corpo: { refresh: atual.tokens.refresh } });
175
+ atual.tokens = { acesso: par.acesso, refresh: par.refresh, acessoExpiraEm: par.acessoExpiraEm };
176
+ await this.gravar(atual);
177
+ return par.acesso;
178
+ };
179
+ const externa = this.cliente.travar;
180
+ return externa ? externa(`telepapo:token:${this.id}`, renovar) : renovar();
163
181
  }
164
182
 
165
183
  async api(metodo, caminho, corpo) {
@@ -260,7 +278,7 @@ class Conta {
260
278
  for (const n of [...(d.faltando ?? []), ...(d.desatualizados ?? [])]) await this.abrirSessoes(usuario, n);
261
279
  }
262
280
 
263
- async enviar1a1(dest, c) {
281
+ async enviar1a1(dest, c, { efemero = false } = {}) {
264
282
  const claro = new TextEncoder().encode(JSON.stringify(c));
265
283
  const cat = categoria(c);
266
284
  const imp = await impressao(c);
@@ -269,7 +287,7 @@ class Conta {
269
287
  if (!comSessao.length) await this.abrirSessoes(dest);
270
288
  const envelopes = await this.cifrarPara(dest, claro);
271
289
  try {
272
- await this.api('POST', `/v1/mensagens/${dest}`, { idCliente: c.id.replace(/-/g, ''), envelopes, categoria: cat, ...(imp ? { impressao: imp } : {}) });
290
+ await this.api('POST', `/v1/mensagens/${dest}`, { idCliente: c.id.replace(/-/g, ''), envelopes, categoria: cat, ...(imp ? { impressao: imp } : {}), ...(efemero ? { efemero: true } : {}) });
273
291
  return;
274
292
  } catch (e) {
275
293
  if (tentativa >= 3 || (e.status !== 409 && e.status !== 410)) throw e;
@@ -297,9 +315,18 @@ class Conta {
297
315
  });
298
316
  }
299
317
 
300
- 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 } = {}) {
301
323
  if (!texto?.trim()) throw new Error('Texto vazio.');
302
- 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
+ });
303
330
  }
304
331
 
305
332
  /** Avisa quem escreveu que a conta leu (e o celular do dono também). */
@@ -311,6 +338,74 @@ class Conta {
311
338
  return this.enviar(para, { tipo: 'reacao', alvo: mensagemId, emoji: emoji ?? '' });
312
339
  }
313
340
 
341
+ /** "Digitando…" (efêmero: só chega a quem estiver online; não vai para o celular do dono). */
342
+ digitando(para, ativo = true) {
343
+ return this.trava(async () => {
344
+ const c = { v: 1, id: randomUUID(), em: Date.now(), tipo: 'digitando', estado: ativo ? 'inicio' : 'fim' };
345
+ try {
346
+ await this.enviar1a1(para, c, { efemero: true });
347
+ } catch (e) {
348
+ if (!(e instanceof SemAparelho)) throw e;
349
+ }
350
+ });
351
+ }
352
+
353
+ // ------------------------------------------------------------ anexos
354
+
355
+ /**
356
+ * Cifra e sobe um arquivo; devolve o `anexo` para pôr numa mensagem `midia`.
357
+ * @param {Buffer|Uint8Array} dados
358
+ * @param {{mime: string, nome?: string, largura?: number, altura?: number, duracaoMs?: number, onda?: string, miniatura?: string}} info
359
+ */
360
+ async subirAnexo(dados, info = {}) {
361
+ if (!info.mime) throw new Error('Informe o mime do anexo.');
362
+ const { blob, chave, digest } = prepararAnexo(dados);
363
+ const { id } = await this.api('POST', '/v1/anexos', { tamanho: blob.length });
364
+ const token = await this.token();
365
+ const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${id}/conteudo`, {
366
+ method: 'PUT',
367
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.3' },
368
+ body: blob,
369
+ });
370
+ if (!r.ok) {
371
+ const texto = await r.text();
372
+ throw new ErroTelePapo(r.status, texto ? JSON.parse(texto) : null);
373
+ }
374
+ const extra = Object.fromEntries(['nome', 'largura', 'altura', 'duracaoMs', 'onda', 'miniatura'].filter((k) => info[k] != null).map((k) => [k, info[k]]));
375
+ return { id, chave, digest, tamanho: Buffer.byteLength(Buffer.from(dados)), mime: info.mime, ...extra };
376
+ }
377
+
378
+ /** Baixa, confere e decifra o anexo de uma mensagem recebida (`mensagem.conteudo.anexo`). */
379
+ async baixarAnexo(anexo) {
380
+ const token = await this.token();
381
+ 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' },
383
+ });
384
+ if (!r.ok) {
385
+ const texto = await r.text();
386
+ throw new ErroTelePapo(r.status, texto ? JSON.parse(texto) : null);
387
+ }
388
+ return abrirAnexo(Buffer.from(await r.arrayBuffer()), anexo);
389
+ }
390
+
391
+ /**
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.
395
+ */
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
+ });
406
+ return { ...r, anexo };
407
+ }
408
+
314
409
  /** Repõe as pré-chaves do aparelho quando o estoque no servidor baixa (rode de tempos em tempos). */
315
410
  async garantirPreKeys() {
316
411
  const c = await this.api('GET', '/v1/chaves/contagem');