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