@telepapo/sdk 0.1.0 → 0.2.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.2.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/index.js CHANGED
@@ -9,11 +9,13 @@ 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';
12
13
 
13
14
  const require = createRequire(import.meta.url);
14
15
  const { Signal } = require('../signal/telepapo_signal.js');
15
16
 
16
17
  export { ArmazenamentoArquivo, ArmazenamentoMemoria, verificarAssinatura };
18
+ export { cifrarAnexo, decifrarAnexo } from './anexos.js';
17
19
 
18
20
  export class ErroTelePapo extends Error {
19
21
  constructor(status, corpo) {
@@ -36,8 +38,12 @@ export class TelePapo {
36
38
  * @param {string} [o.chaveWebhook] chave de assinatura do webhook (portal)
37
39
  * @param {string} [o.apiUrl] padrão https://api.telepapo.com.br
38
40
  * @param {object} [o.armazenamento] { ler, gravar, apagar } — padrão: memória
41
+ * @param {(chave: string, f: () => Promise<any>) => Promise<any>} [o.travar]
42
+ * Trava ENTRE processos (ex.: Redis). Obrigatória se mais de um processo usa
43
+ * a mesma conta: o estado do Signal e o refresh token não aguentam dois
44
+ * escritores ao mesmo tempo. Sem ela, só a fila deste processo.
39
45
  */
40
- constructor({ clientId, credencial, chaveWebhook, apiUrl = 'https://api.telepapo.com.br', armazenamento = new ArmazenamentoMemoria(), fetch = globalThis.fetch }) {
46
+ constructor({ clientId, credencial, chaveWebhook, apiUrl = 'https://api.telepapo.com.br', armazenamento = new ArmazenamentoMemoria(), fetch = globalThis.fetch, travar = null }) {
41
47
  if (!clientId || !credencial) throw new Error('clientId e credencial são obrigatórios.');
42
48
  this.clientId = clientId;
43
49
  this.credencial = credencial;
@@ -45,11 +51,12 @@ export class TelePapo {
45
51
  this.apiUrl = apiUrl.replace(/\/$/, '');
46
52
  this.armazenamento = armazenamento;
47
53
  this.fetch = fetch;
54
+ this.travar = travar;
48
55
  this.contas = new Map();
49
56
  }
50
57
 
51
58
  async http(metodo, caminho, { corpo, token, basic } = {}) {
52
- const headers = { 'User-Agent': 'telepapo-sdk/0.1' };
59
+ const headers = { 'User-Agent': 'telepapo-sdk/0.2' };
53
60
  if (corpo !== undefined) headers['Content-Type'] = 'application/json';
54
61
  if (token) headers.Authorization = `Bearer ${token}`;
55
62
  if (basic) headers.Authorization = 'Basic ' + Buffer.from(`${this.clientId}:${this.credencial}`).toString('base64');
@@ -128,7 +135,8 @@ class Conta {
128
135
 
129
136
  /** Uma operação por vez por conta (o estado do Signal não pode ser usado por duas ao mesmo tempo). */
130
137
  trava(f) {
131
- const vez = this.fila.then(f);
138
+ const externa = this.cliente.travar;
139
+ const vez = this.fila.then(() => (externa ? externa(`telepapo:conta:${this.id}`, f) : f()));
132
140
  this.fila = vez.catch(() => undefined);
133
141
  return vez;
134
142
  }
@@ -154,12 +162,20 @@ class Conta {
154
162
  }
155
163
 
156
164
  async token() {
165
+ const valido = (e) => new Date(e.tokens.acessoExpiraEm).getTime() - Date.now() > 60_000;
157
166
  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;
167
+ if (valido(e)) return e.tokens.acesso;
168
+ const renovar = async () => {
169
+ // Relê dentro da trava: outro processo pode ter acabado de renovar.
170
+ const atual = await this.estado();
171
+ if (valido(atual)) return atual.tokens.acesso;
172
+ const par = await this.cliente.http('POST', '/v1/auth/renovar', { corpo: { refresh: atual.tokens.refresh } });
173
+ atual.tokens = { acesso: par.acesso, refresh: par.refresh, acessoExpiraEm: par.acessoExpiraEm };
174
+ await this.gravar(atual);
175
+ return par.acesso;
176
+ };
177
+ const externa = this.cliente.travar;
178
+ return externa ? externa(`telepapo:token:${this.id}`, renovar) : renovar();
163
179
  }
164
180
 
165
181
  async api(metodo, caminho, corpo) {
@@ -260,7 +276,7 @@ class Conta {
260
276
  for (const n of [...(d.faltando ?? []), ...(d.desatualizados ?? [])]) await this.abrirSessoes(usuario, n);
261
277
  }
262
278
 
263
- async enviar1a1(dest, c) {
279
+ async enviar1a1(dest, c, { efemero = false } = {}) {
264
280
  const claro = new TextEncoder().encode(JSON.stringify(c));
265
281
  const cat = categoria(c);
266
282
  const imp = await impressao(c);
@@ -269,7 +285,7 @@ class Conta {
269
285
  if (!comSessao.length) await this.abrirSessoes(dest);
270
286
  const envelopes = await this.cifrarPara(dest, claro);
271
287
  try {
272
- await this.api('POST', `/v1/mensagens/${dest}`, { idCliente: c.id.replace(/-/g, ''), envelopes, categoria: cat, ...(imp ? { impressao: imp } : {}) });
288
+ await this.api('POST', `/v1/mensagens/${dest}`, { idCliente: c.id.replace(/-/g, ''), envelopes, categoria: cat, ...(imp ? { impressao: imp } : {}), ...(efemero ? { efemero: true } : {}) });
273
289
  return;
274
290
  } catch (e) {
275
291
  if (tentativa >= 3 || (e.status !== 409 && e.status !== 410)) throw e;
@@ -311,6 +327,67 @@ class Conta {
311
327
  return this.enviar(para, { tipo: 'reacao', alvo: mensagemId, emoji: emoji ?? '' });
312
328
  }
313
329
 
330
+ /** "Digitando…" (efêmero: só chega a quem estiver online; não vai para o celular do dono). */
331
+ digitando(para, ativo = true) {
332
+ return this.trava(async () => {
333
+ const c = { v: 1, id: randomUUID(), em: Date.now(), tipo: 'digitando', estado: ativo ? 'inicio' : 'fim' };
334
+ try {
335
+ await this.enviar1a1(para, c, { efemero: true });
336
+ } catch (e) {
337
+ if (!(e instanceof SemAparelho)) throw e;
338
+ }
339
+ });
340
+ }
341
+
342
+ // ------------------------------------------------------------ anexos
343
+
344
+ /**
345
+ * Cifra e sobe um arquivo; devolve o `anexo` para pôr numa mensagem `midia`.
346
+ * @param {Buffer|Uint8Array} dados
347
+ * @param {{mime: string, nome?: string, largura?: number, altura?: number, duracaoMs?: number, miniatura?: string}} info
348
+ */
349
+ async subirAnexo(dados, info = {}) {
350
+ if (!info.mime) throw new Error('Informe o mime do anexo.');
351
+ const { blob, chave, digest } = prepararAnexo(dados);
352
+ const { id } = await this.api('POST', '/v1/anexos', { tamanho: blob.length });
353
+ const token = await this.token();
354
+ const r = await this.cliente.fetch(`${this.cliente.apiUrl}/v1/anexos/${id}/conteudo`, {
355
+ method: 'PUT',
356
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'User-Agent': 'telepapo-sdk/0.2' },
357
+ body: blob,
358
+ });
359
+ if (!r.ok) {
360
+ const texto = await r.text();
361
+ throw new ErroTelePapo(r.status, texto ? JSON.parse(texto) : null);
362
+ }
363
+ const extra = Object.fromEntries(['nome', 'largura', 'altura', 'duracaoMs', 'miniatura'].filter((k) => info[k] != null).map((k) => [k, info[k]]));
364
+ return { id, chave, digest, tamanho: Buffer.byteLength(Buffer.from(dados)), mime: info.mime, ...extra };
365
+ }
366
+
367
+ /** Baixa, confere e decifra o anexo de uma mensagem recebida (`mensagem.conteudo.anexo`). */
368
+ async baixarAnexo(anexo) {
369
+ const token = await this.token();
370
+ 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' },
372
+ });
373
+ if (!r.ok) {
374
+ const texto = await r.text();
375
+ throw new ErroTelePapo(r.status, texto ? JSON.parse(texto) : null);
376
+ }
377
+ return abrirAnexo(Buffer.from(await r.arrayBuffer()), anexo);
378
+ }
379
+
380
+ /**
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.
383
+ */
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);
388
+ return { ...r, anexo };
389
+ }
390
+
314
391
  /** Repõe as pré-chaves do aparelho quando o estoque no servidor baixa (rode de tempos em tempos). */
315
392
  async garantirPreKeys() {
316
393
  const c = await this.api('GET', '/v1/chaves/contagem');