@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 +6 -2
- package/package.json +1 -1
- package/src/anexos.js +38 -0
- package/src/index.js +87 -10
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`, `
|
|
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
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.
|
|
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
|
|
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 (
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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');
|