@telepapo/sdk 0.0.0-stage → 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/src/index.js ADDED
@@ -0,0 +1,408 @@
1
+ // @telepapo/sdk — o lado do servidor de um app do TelePapo Developers.
2
+ //
3
+ // O app é um APARELHO da conta comercial que o autorizou: tem chaves Signal
4
+ // próprias (geradas aqui, nunca saem do seu servidor), recebe uma cópia
5
+ // cifrada de cada mensagem que chega para a conta e envia em nome dela. O
6
+ // celular do dono vê o que o app mandou (o SDK manda o "espelho").
7
+ import { createRequire } from 'node:module';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { ArmazenamentoArquivo, ArmazenamentoMemoria } from './armazenamento.js';
10
+ import { verificarAssinatura } from './webhook.js';
11
+ import { categoria, impressao } from './categoria.js';
12
+ import { abrirAnexo, prepararAnexo } from './anexos.js';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { Signal } = require('../signal/telepapo_signal.js');
16
+
17
+ export { ArmazenamentoArquivo, ArmazenamentoMemoria, verificarAssinatura };
18
+ export { cifrarAnexo, decifrarAnexo } from './anexos.js';
19
+
20
+ export class ErroTelePapo extends Error {
21
+ constructor(status, corpo) {
22
+ super(corpo?.mensagem || `Erro ${status}`);
23
+ this.status = status;
24
+ this.codigo = corpo?.codigo;
25
+ this.corpo = corpo;
26
+ }
27
+ }
28
+
29
+ class SemAparelho extends Error {}
30
+
31
+ const TIPOS_DE_MENSAGEM = new Set(['texto', 'midia', 'localizacao', 'contato', 'animacao', 'produto']);
32
+
33
+ export class TelePapo {
34
+ /**
35
+ * @param {object} o
36
+ * @param {string} o.clientId client_id do app (portal)
37
+ * @param {string} o.credencial client_secret do app (portal; mostrado uma vez)
38
+ * @param {string} [o.chaveWebhook] chave de assinatura do webhook (portal)
39
+ * @param {string} [o.apiUrl] padrão https://api.telepapo.com.br
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.
45
+ */
46
+ constructor({ clientId, credencial, chaveWebhook, apiUrl = 'https://api.telepapo.com.br', armazenamento = new ArmazenamentoMemoria(), fetch = globalThis.fetch, travar = null }) {
47
+ if (!clientId || !credencial) throw new Error('clientId e credencial são obrigatórios.');
48
+ this.clientId = clientId;
49
+ this.credencial = credencial;
50
+ this.chaveWebhook = chaveWebhook;
51
+ this.apiUrl = apiUrl.replace(/\/$/, '');
52
+ this.armazenamento = armazenamento;
53
+ this.fetch = fetch;
54
+ this.travar = travar;
55
+ this.contas = new Map();
56
+ }
57
+
58
+ async http(metodo, caminho, { corpo, token, basic } = {}) {
59
+ const headers = { 'User-Agent': 'telepapo-sdk/0.2' };
60
+ if (corpo !== undefined) headers['Content-Type'] = 'application/json';
61
+ if (token) headers.Authorization = `Bearer ${token}`;
62
+ if (basic) headers.Authorization = 'Basic ' + Buffer.from(`${this.clientId}:${this.credencial}`).toString('base64');
63
+ const r = await this.fetch(this.apiUrl + caminho, { method: metodo, headers, body: corpo === undefined ? undefined : JSON.stringify(corpo) });
64
+ const texto = await r.text();
65
+ const json = texto ? JSON.parse(texto) : null;
66
+ if (!r.ok) throw new ErroTelePapo(r.status, json);
67
+ return json;
68
+ }
69
+
70
+ /**
71
+ * Passo 1 da autorização: gera a identidade Signal do novo aparelho e pede a
72
+ * autorização. Mande o usuário para `url` (ele aprova no celular); depois o
73
+ * TelePapo o devolve para `redirectUri?code=…&state=<estado>`.
74
+ */
75
+ async iniciarAutorizacao({ redirectUri, estado = randomUUID(), escopos } = {}) {
76
+ const s = Signal.novo();
77
+ const chaves = JSON.parse(s.chavesIniciais(Date.now()));
78
+ const r = await this.http('POST', '/v1/autorizacoes', { basic: true, corpo: { chaves, redirectUri, estado, ...(escopos ? { escopos } : {}) } });
79
+ await this.armazenamento.gravar(`pedido:${estado}`, { signal: s.exportar(), id: r.id, criadoEm: Date.now() });
80
+ return { url: r.url, estado, id: r.id, expiraEm: r.expiraEm };
81
+ }
82
+
83
+ /** Passo 2: no seu `redirectUri`, troque o `code` (e o `state`) pela conta autorizada. */
84
+ async concluirAutorizacao({ code, estado }) {
85
+ const pedido = await this.armazenamento.ler(`pedido:${estado}`);
86
+ if (!pedido) throw new Error('Pedido de autorização desconhecido (estado inválido ou vencido).');
87
+ const t = await this.http('POST', '/v1/token', { basic: true, corpo: { code } });
88
+ const s = Signal.carregar(pedido.signal);
89
+ // A chave registrada na conta tem de ser a que geramos aqui.
90
+ if (t.identityKey !== s.identidadePublica()) throw new Error('A identidade registrada não confere.');
91
+ s.definirEndereco(t.conta.id, t.dispositivo.numero);
92
+ const estadoConta = {
93
+ id: t.conta.id, nome: t.conta.nome, username: t.conta.username, numero: t.dispositivo.numero, dispositivoId: t.dispositivo.id,
94
+ escopos: t.escopos, tokens: { acesso: t.acesso, refresh: t.refresh, acessoExpiraEm: t.acessoExpiraEm }, signal: s.exportar(),
95
+ };
96
+ await this.armazenamento.gravar(`conta:${t.conta.id}`, estadoConta);
97
+ await this.armazenamento.apagar(`pedido:${estado}`);
98
+ return this.conta(t.conta.id);
99
+ }
100
+
101
+ /** Uma conta já autorizada. */
102
+ conta(id) {
103
+ if (!this.contas.has(id)) this.contas.set(id, new Conta(this, id));
104
+ return this.contas.get(id);
105
+ }
106
+
107
+ /**
108
+ * Webhook: confere a assinatura com o corpo CRU e devolve o evento. Para
109
+ * `mensagem.recebida`, já decifra e confirma o envelope (`evento.mensagem`).
110
+ */
111
+ async processarWebhook(corpoCru, assinatura) {
112
+ if (!this.chaveWebhook) throw new Error('Configure chaveWebhook para receber webhooks.');
113
+ if (!verificarAssinatura(corpoCru, assinatura, this.chaveWebhook)) throw new Error('Assinatura do webhook inválida.');
114
+ const ev = JSON.parse(Buffer.isBuffer(corpoCru) ? corpoCru.toString('utf8') : corpoCru);
115
+ if (ev.tipo === 'mensagem.recebida') {
116
+ const conta = this.conta(ev.conta);
117
+ const mensagem = await conta.processarEnvelope(ev.dados.envelope);
118
+ await conta.confirmar([ev.dados.envelope.id]);
119
+ return { ...ev, mensagem };
120
+ }
121
+ if (ev.tipo === 'autorizacao.revogada') {
122
+ await this.armazenamento.apagar(`conta:${ev.conta}`);
123
+ this.contas.delete(ev.conta);
124
+ }
125
+ return ev;
126
+ }
127
+ }
128
+
129
+ class Conta {
130
+ constructor(cliente, id) {
131
+ this.cliente = cliente;
132
+ this.id = id;
133
+ this.fila = Promise.resolve();
134
+ }
135
+
136
+ /** Uma operação por vez por conta (o estado do Signal não pode ser usado por duas ao mesmo tempo). */
137
+ trava(f) {
138
+ const externa = this.cliente.travar;
139
+ const vez = this.fila.then(() => (externa ? externa(`telepapo:conta:${this.id}`, f) : f()));
140
+ this.fila = vez.catch(() => undefined);
141
+ return vez;
142
+ }
143
+
144
+ async estado() {
145
+ const e = await this.cliente.armazenamento.ler(`conta:${this.id}`);
146
+ if (!e) throw new Error('Conta não autorizada (ou o app foi desconectado).');
147
+ return e;
148
+ }
149
+
150
+ async gravar(e) {
151
+ await this.cliente.armazenamento.gravar(`conta:${this.id}`, e);
152
+ }
153
+
154
+ /** Mexe no Signal: carrega, roda e grava (antes de confirmar qualquer envelope). */
155
+ async comSignal(f) {
156
+ const e = await this.estado();
157
+ const s = Signal.carregar(e.signal);
158
+ const r = await f(s, e);
159
+ e.signal = s.exportar();
160
+ await this.gravar(e);
161
+ return r;
162
+ }
163
+
164
+ async token() {
165
+ const valido = (e) => new Date(e.tokens.acessoExpiraEm).getTime() - Date.now() > 60_000;
166
+ const e = await this.estado();
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();
179
+ }
180
+
181
+ async api(metodo, caminho, corpo) {
182
+ return this.cliente.http(metodo, caminho, { corpo, token: await this.token() });
183
+ }
184
+
185
+ async dados() {
186
+ const e = await this.estado();
187
+ return { id: e.id, nome: e.nome, username: e.username, escopos: e.escopos };
188
+ }
189
+
190
+ // ------------------------------------------------------------ receber
191
+
192
+ /** Busca o que está pendente (alternativa/complemento ao webhook), decifra e confirma. */
193
+ receber() {
194
+ return this.trava(async () => {
195
+ const r = await this.api('GET', '/v1/mensagens');
196
+ const mensagens = [];
197
+ for (const env of r.envelopes) {
198
+ const m = await this.decifrarEnvelope(env);
199
+ if (m) mensagens.push(m);
200
+ }
201
+ if (r.envelopes.length) await this.api('POST', '/v1/mensagens-confirmadas', { ids: r.envelopes.map((e) => e.id) });
202
+ return mensagens;
203
+ });
204
+ }
205
+
206
+ processarEnvelope(env) {
207
+ return this.trava(() => this.decifrarEnvelope(env));
208
+ }
209
+
210
+ confirmar(ids) {
211
+ return this.api('POST', '/v1/mensagens-confirmadas', { ids });
212
+ }
213
+
214
+ /**
215
+ * Decifra um envelope e devolve a mensagem:
216
+ * `{ id, de, grupo, tipo, texto, conteudo, em, minha }`. `minha` = escrita
217
+ * pelo dono em outro aparelho (celular/navegador): `conversa` diz onde.
218
+ * Repetido ou ilegível: null.
219
+ */
220
+ async decifrarEnvelope(env) {
221
+ let claro;
222
+ try {
223
+ claro = await this.comSignal((s) => s.decifrar(env.de, env.deDispositivo, env.tipo, env.conteudo));
224
+ } catch (e) {
225
+ if (/duplicate|old counter/i.test(String(e?.message ?? e))) return null;
226
+ return { id: env.id, de: env.de, erro: 'nao_decifrou', em: Date.parse(env.recebidoEm) };
227
+ }
228
+ let c;
229
+ try {
230
+ c = JSON.parse(Buffer.from(claro).toString('utf8'));
231
+ } catch {
232
+ return null;
233
+ }
234
+ const minha = env.de === this.id;
235
+ return {
236
+ id: c.id,
237
+ de: env.de,
238
+ deDispositivo: env.deDispositivo,
239
+ grupo: env.grupoId ?? null,
240
+ conversa: env.grupoId ?? (minha ? c.para ?? null : env.de),
241
+ tipo: c.tipo,
242
+ texto: c.texto ?? null,
243
+ em: c.em,
244
+ minha,
245
+ conteudo: c,
246
+ };
247
+ }
248
+
249
+ // ------------------------------------------------------------ enviar
250
+
251
+ async abrirSessoes(usuario, numero) {
252
+ let r;
253
+ try {
254
+ r = await this.api('GET', `/v1/chaves/${usuario}` + (numero ? `?dispositivo=${numero}` : ''));
255
+ } catch (e) {
256
+ if (e.status === 404) throw new SemAparelho('O contato não está conectado em nenhum aparelho.');
257
+ throw e;
258
+ }
259
+ if (!r.pacotes.length) throw new SemAparelho('O contato não está conectado em nenhum aparelho.');
260
+ await this.comSignal((s) => {
261
+ for (const p of r.pacotes) s.abrirSessao(usuario, JSON.stringify(p), Date.now());
262
+ });
263
+ }
264
+
265
+ async cifrarPara(usuario, claro) {
266
+ return this.comSignal((s) => [...s.dispositivosComSessao(usuario)].map((n) => {
267
+ const r = JSON.parse(s.cifrar(usuario, n, claro, Date.now()));
268
+ return { dispositivo: n, registrationId: r.registrationId, tipo: r.tipo, conteudo: r.conteudo };
269
+ }));
270
+ }
271
+
272
+ async corrigir(usuario, d) {
273
+ await this.comSignal((s) => {
274
+ for (const n of [...(d.sobrando ?? []), ...(d.desatualizados ?? [])]) s.apagarSessao(usuario, n);
275
+ });
276
+ for (const n of [...(d.faltando ?? []), ...(d.desatualizados ?? [])]) await this.abrirSessoes(usuario, n);
277
+ }
278
+
279
+ async enviar1a1(dest, c, { efemero = false } = {}) {
280
+ const claro = new TextEncoder().encode(JSON.stringify(c));
281
+ const cat = categoria(c);
282
+ const imp = await impressao(c);
283
+ for (let tentativa = 1; ; tentativa++) {
284
+ const comSessao = await this.comSignal((s) => [...s.dispositivosComSessao(dest)]);
285
+ if (!comSessao.length) await this.abrirSessoes(dest);
286
+ const envelopes = await this.cifrarPara(dest, claro);
287
+ try {
288
+ await this.api('POST', `/v1/mensagens/${dest}`, { idCliente: c.id.replace(/-/g, ''), envelopes, categoria: cat, ...(imp ? { impressao: imp } : {}), ...(efemero ? { efemero: true } : {}) });
289
+ return;
290
+ } catch (e) {
291
+ if (tentativa >= 3 || (e.status !== 409 && e.status !== 410)) throw e;
292
+ await this.corrigir(dest, e.corpo ?? {});
293
+ }
294
+ }
295
+ }
296
+
297
+ /** Espelho: os outros aparelhos do dono (celular, navegador) ficam sabendo do que o app mandou. */
298
+ async espelhar(conversa, c) {
299
+ try {
300
+ await this.enviar1a1(this.id, { ...c, para: conversa });
301
+ } catch (e) {
302
+ if (!(e instanceof SemAparelho)) throw e;
303
+ }
304
+ }
305
+
306
+ /** Envia um Conteudo (formato do TelePapo) para um contato (1:1). */
307
+ enviar(para, conteudo) {
308
+ return this.trava(async () => {
309
+ const c = { v: 1, id: randomUUID(), em: Date.now(), ...conteudo };
310
+ await this.enviar1a1(para, c);
311
+ await this.espelhar(para, c);
312
+ return { id: c.id };
313
+ });
314
+ }
315
+
316
+ enviarTexto(para, texto, { resposta } = {}) {
317
+ if (!texto?.trim()) throw new Error('Texto vazio.');
318
+ return this.enviar(para, { tipo: 'texto', texto: texto.slice(0, 65000), ...(resposta ? { resposta } : {}) });
319
+ }
320
+
321
+ /** Avisa quem escreveu que a conta leu (e o celular do dono também). */
322
+ marcarLida(de, ids) {
323
+ return this.enviar(de, { tipo: 'recibo', estado: 'lido', ids });
324
+ }
325
+
326
+ reagir(para, mensagemId, emoji) {
327
+ return this.enviar(para, { tipo: 'reacao', alvo: mensagemId, emoji: emoji ?? '' });
328
+ }
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
+
391
+ /** Repõe as pré-chaves do aparelho quando o estoque no servidor baixa (rode de tempos em tempos). */
392
+ async garantirPreKeys() {
393
+ const c = await this.api('GET', '/v1/chaves/contagem');
394
+ const pre = c.preKeys < 15 ? 50 : 0;
395
+ const kyber = c.kyberPreKeys < 15 ? 20 : 0;
396
+ if (!pre && !kyber) return false;
397
+ const lote = await this.trava(() => this.comSignal((s) => JSON.parse(s.reposicao(Date.now(), pre, kyber))));
398
+ await this.api('PUT', '/v1/chaves', lote);
399
+ return true;
400
+ }
401
+
402
+ /** O app sai da conta (o dono também pode desconectar pelo celular). */
403
+ async desconectar() {
404
+ await this.api('POST', '/v1/auth/sair');
405
+ await this.cliente.armazenamento.apagar(`conta:${this.id}`);
406
+ this.cliente.contas.delete(this.id);
407
+ }
408
+ }
package/src/webhook.js ADDED
@@ -0,0 +1,20 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ /**
4
+ * Confere o cabeçalho `X-TelePapo-Assinatura: t=<segundos>,v1=<hex>` de um
5
+ * webhook: v1 = HMAC-SHA256(chave do webhook, `${t}.${corpo}`).
6
+ *
7
+ * Use o CORPO CRU da requisição (os bytes exatos, antes de qualquer
8
+ * JSON.parse). Recusa assinatura com mais de `toleranciaSegundos` (padrão 5
9
+ * min): um evento capturado não pode ser reapresentado depois.
10
+ */
11
+ export function verificarAssinatura(corpo, cabecalho, chave, { toleranciaSegundos = 300, agora = Date.now() } = {}) {
12
+ const texto = Buffer.isBuffer(corpo) ? corpo.toString('utf8') : String(corpo);
13
+ const partes = Object.fromEntries(String(cabecalho ?? '').split(',').map((p) => p.trim().split('=')));
14
+ const t = Number(partes.t);
15
+ if (!Number.isInteger(t) || !partes.v1) return false;
16
+ if (Math.abs(agora / 1000 - t) > toleranciaSegundos) return false;
17
+ const esperado = createHmac('sha256', chave).update(`${t}.${texto}`).digest();
18
+ const recebido = Buffer.from(partes.v1, 'hex');
19
+ return recebido.length === esperado.length && timingSafeEqual(recebido, esperado);
20
+ }