@usebridger/transport-web 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/LICENSE +202 -0
- package/dist/client.d.mts +51 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +161 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +45 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +709 -0
- package/dist/index.mjs.map +1 -0
- package/dist/protocol-D7nZkQui.d.mts +108 -0
- package/dist/protocol-D7nZkQui.d.mts.map +1 -0
- package/dist/protocol-a9D3CajG.mjs +25 -0
- package/dist/protocol-a9D3CajG.mjs.map +1 -0
- package/package.json +42 -3
- package/README.md +0 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { a as CLOSE_TOO_BIG, c as DEFAULT_CONVERSATION, i as CLOSE_INVALID_AUTH, l as MAX_ATTACHMENTS, n as CLOSE_FORBIDDEN, o as CLOSE_UNAUTHORIZED, r as CLOSE_GOING_AWAY, s as CONVERSATION_PATTERN, t as AUTH_TIMEOUT_MS, u as MAX_FRAME_BYTES } from "./protocol-a9D3CajG.mjs";
|
|
2
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
3
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { UnsupportedError } from "@usebridger/core";
|
|
5
|
+
import { TypedEmitter, assertCanSend, createMessage, groupActionCapability } from "@usebridger/core/adapter";
|
|
6
|
+
//#region src/jwt.ts
|
|
7
|
+
/** Lançado para qualquer token recusado; a mensagem é do `jose` ou do transport. */
|
|
8
|
+
var InvalidTokenError = class extends Error {
|
|
9
|
+
name = "InvalidTokenError";
|
|
10
|
+
};
|
|
11
|
+
const SECRET_ALGORITHMS = ["HS256"];
|
|
12
|
+
const JWKS_ALGORITHMS = ["RS256", "ES256"];
|
|
13
|
+
/** Monta o verificador. Valida a config na hora: a fábrica a chama, e o erro vira `BotConfigError`. */
|
|
14
|
+
function createVerifier(auth) {
|
|
15
|
+
let key;
|
|
16
|
+
let algorithms;
|
|
17
|
+
if (typeof auth.secret === "string") {
|
|
18
|
+
if (Buffer.byteLength(auth.secret) < 32) throw new TypeError("web: auth.secret precisa de pelo menos 32 bytes");
|
|
19
|
+
key = new TextEncoder().encode(auth.secret);
|
|
20
|
+
algorithms = SECRET_ALGORITHMS;
|
|
21
|
+
} else if (typeof auth.jwksUrl === "string") {
|
|
22
|
+
key = createRemoteJWKSet(new URL(auth.jwksUrl));
|
|
23
|
+
algorithms = JWKS_ALGORITHMS;
|
|
24
|
+
} else throw new TypeError("web: auth precisa de `secret` ou de `jwksUrl`");
|
|
25
|
+
const options = {
|
|
26
|
+
algorithms,
|
|
27
|
+
requiredClaims: ["exp", "sub"],
|
|
28
|
+
...auth.issuer === void 0 ? null : { issuer: auth.issuer },
|
|
29
|
+
...auth.audience === void 0 ? null : { audience: auth.audience }
|
|
30
|
+
};
|
|
31
|
+
return async (token) => {
|
|
32
|
+
let payload;
|
|
33
|
+
try {
|
|
34
|
+
({payload} = key instanceof Uint8Array ? await jwtVerify(token, key, options) : await jwtVerify(token, key, options));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw new InvalidTokenError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
37
|
+
}
|
|
38
|
+
if (typeof payload.sub !== "string" || payload.sub === "" || typeof payload.exp !== "number") throw new InvalidTokenError("token sem `sub` em texto ou sem `exp` numérico");
|
|
39
|
+
return {
|
|
40
|
+
sub: payload.sub,
|
|
41
|
+
expiresAt: payload.exp * 1e3,
|
|
42
|
+
claims: payload
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Token do header `Authorization: Bearer <jwt>`, ou `null`. */
|
|
47
|
+
function bearerToken(request) {
|
|
48
|
+
const header = request.headers.get("authorization");
|
|
49
|
+
return (header === null ? null : /^Bearer\s+(\S+)$/i.exec(header))?.[1] ?? null;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/media-store.ts
|
|
53
|
+
const MEDIA_TTL_MS = 36e5;
|
|
54
|
+
var MediaStore = class {
|
|
55
|
+
#entries = /* @__PURE__ */ new Map();
|
|
56
|
+
#bytes = 0;
|
|
57
|
+
#now;
|
|
58
|
+
#maxBytes;
|
|
59
|
+
constructor(options = {}) {
|
|
60
|
+
this.#now = options.now ?? Date.now;
|
|
61
|
+
this.#maxBytes = options.maxBytes ?? 268435456;
|
|
62
|
+
}
|
|
63
|
+
/** Guarda e devolve um ID aleatório de 128 bits, que serve de URL de download. */
|
|
64
|
+
put(media) {
|
|
65
|
+
this.#prune();
|
|
66
|
+
const id = randomBytes(16).toString("base64url");
|
|
67
|
+
this.#entries.set(id, {
|
|
68
|
+
...media,
|
|
69
|
+
expiresAt: this.#now() + MEDIA_TTL_MS
|
|
70
|
+
});
|
|
71
|
+
this.#bytes += media.bytes.length;
|
|
72
|
+
for (const [oldest, entry] of this.#entries) {
|
|
73
|
+
if (this.#bytes <= this.#maxBytes || oldest === id) break;
|
|
74
|
+
this.#delete(oldest, entry);
|
|
75
|
+
}
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
/** O arquivo, se ainda vale. */
|
|
79
|
+
get(id) {
|
|
80
|
+
const entry = this.#entries.get(id);
|
|
81
|
+
if (entry === void 0) return void 0;
|
|
82
|
+
if (entry.expiresAt <= this.#now()) {
|
|
83
|
+
this.#delete(id, entry);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Tira os uploads de `owner` do mapa, todos ou nenhum: a mensagem passa a segurar os bytes, e o
|
|
90
|
+
* mesmo upload não entra em duas mensagens. `undefined` se algum não existe, venceu ou é de
|
|
91
|
+
* outro contato.
|
|
92
|
+
*/
|
|
93
|
+
takeAll(ids, owner) {
|
|
94
|
+
const now = this.#now();
|
|
95
|
+
const found = [];
|
|
96
|
+
for (const id of ids) {
|
|
97
|
+
const entry = this.#entries.get(id);
|
|
98
|
+
if (entry === void 0 || entry.owner !== owner || entry.expiresAt <= now) return void 0;
|
|
99
|
+
found.push(entry);
|
|
100
|
+
}
|
|
101
|
+
for (const [index, id] of ids.entries()) if (this.#entries.delete(id)) this.#bytes -= found[index].bytes.length;
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
clear() {
|
|
105
|
+
this.#entries.clear();
|
|
106
|
+
this.#bytes = 0;
|
|
107
|
+
}
|
|
108
|
+
get size() {
|
|
109
|
+
return this.#entries.size;
|
|
110
|
+
}
|
|
111
|
+
#prune() {
|
|
112
|
+
const now = this.#now();
|
|
113
|
+
for (const [id, entry] of this.#entries) {
|
|
114
|
+
if (entry.expiresAt > now) break;
|
|
115
|
+
this.#delete(id, entry);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
#delete(id, entry) {
|
|
119
|
+
this.#entries.delete(id);
|
|
120
|
+
this.#bytes -= entry.bytes.length;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const OUTBOX_TTL_MS = 36e5;
|
|
124
|
+
var Outbox = class {
|
|
125
|
+
#chats = /* @__PURE__ */ new Map();
|
|
126
|
+
#now;
|
|
127
|
+
constructor(now = Date.now) {
|
|
128
|
+
this.#now = now;
|
|
129
|
+
}
|
|
130
|
+
push(chatId, frame) {
|
|
131
|
+
const now = this.#now();
|
|
132
|
+
this.#prune(now);
|
|
133
|
+
const pending = this.#chats.get(chatId) ?? [];
|
|
134
|
+
this.#chats.delete(chatId);
|
|
135
|
+
pending.push({
|
|
136
|
+
frame,
|
|
137
|
+
expiresAt: now + OUTBOX_TTL_MS
|
|
138
|
+
});
|
|
139
|
+
if (pending.length > 100) pending.shift();
|
|
140
|
+
this.#chats.set(chatId, pending);
|
|
141
|
+
}
|
|
142
|
+
/** Tira e devolve o que ainda vale para a conversa, em ordem. */
|
|
143
|
+
drain(chatId) {
|
|
144
|
+
const pending = this.#chats.get(chatId);
|
|
145
|
+
if (pending === void 0) return [];
|
|
146
|
+
this.#chats.delete(chatId);
|
|
147
|
+
const now = this.#now();
|
|
148
|
+
return pending.filter((item) => item.expiresAt > now).map((item) => item.frame);
|
|
149
|
+
}
|
|
150
|
+
clear() {
|
|
151
|
+
this.#chats.clear();
|
|
152
|
+
}
|
|
153
|
+
get size() {
|
|
154
|
+
return this.#chats.size;
|
|
155
|
+
}
|
|
156
|
+
/** Solta as conversas cujo frame mais novo já venceu: todos os outros venceram antes. */
|
|
157
|
+
#prune(now) {
|
|
158
|
+
for (const [chatId, pending] of this.#chats) {
|
|
159
|
+
const newest = pending.at(-1);
|
|
160
|
+
if (newest !== void 0 && newest.expiresAt > now) break;
|
|
161
|
+
this.#chats.delete(chatId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/transport.ts
|
|
167
|
+
const DEFAULT_MEDIA_MAX_BYTES = 10485760;
|
|
168
|
+
const CAPABILITIES = [
|
|
169
|
+
"send.text",
|
|
170
|
+
"send.image",
|
|
171
|
+
"send.video",
|
|
172
|
+
"send.audio",
|
|
173
|
+
"send.document",
|
|
174
|
+
"media.download",
|
|
175
|
+
"actions",
|
|
176
|
+
"quoted",
|
|
177
|
+
"typing",
|
|
178
|
+
"message.edit",
|
|
179
|
+
"message.delete"
|
|
180
|
+
];
|
|
181
|
+
const SELF = {
|
|
182
|
+
id: "bot",
|
|
183
|
+
name: null,
|
|
184
|
+
phone: null,
|
|
185
|
+
isBot: true
|
|
186
|
+
};
|
|
187
|
+
/** Maior atraso que o `setTimeout` aceita; acima dele, o timer dispara na hora. */
|
|
188
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
189
|
+
/** Token recusado por falta do claim de tenant: válido, mas sem lugar neste transport. */
|
|
190
|
+
var ForbiddenError = class extends Error {
|
|
191
|
+
name = "ForbiddenError";
|
|
192
|
+
};
|
|
193
|
+
var WebTransport = class {
|
|
194
|
+
name = "web";
|
|
195
|
+
capabilities = new Set(CAPABILITIES);
|
|
196
|
+
native = null;
|
|
197
|
+
#options;
|
|
198
|
+
#verify;
|
|
199
|
+
#routes;
|
|
200
|
+
#log;
|
|
201
|
+
#events;
|
|
202
|
+
#maxBytes;
|
|
203
|
+
#connections = /* @__PURE__ */ new Set();
|
|
204
|
+
/** Conexões autenticadas por conversa; várias abas na mesma conversa recebem os mesmos frames. */
|
|
205
|
+
#chats = /* @__PURE__ */ new Map();
|
|
206
|
+
#outbox = new Outbox();
|
|
207
|
+
#media = new MediaStore();
|
|
208
|
+
#connected = false;
|
|
209
|
+
#self = null;
|
|
210
|
+
constructor(options, verify, routes, log) {
|
|
211
|
+
this.#options = options;
|
|
212
|
+
this.#verify = verify;
|
|
213
|
+
this.#routes = routes;
|
|
214
|
+
this.#log = log;
|
|
215
|
+
this.#maxBytes = options.media?.maxBytes ?? 10485760;
|
|
216
|
+
this.#events = new TypedEmitter((error, event) => this.#log.error("handler de evento do transport falhou", {
|
|
217
|
+
err: error,
|
|
218
|
+
event
|
|
219
|
+
}));
|
|
220
|
+
routes.ws("/chat", (request) => this.#accept(request));
|
|
221
|
+
routes.route("POST", "/media", (request) => this.#upload(request));
|
|
222
|
+
routes.route("OPTIONS", "/media", (request) => this.#preflight(request));
|
|
223
|
+
routes.route("GET", "/media/:id", (request, { params }) => this.#download(request, params["id"] ?? ""));
|
|
224
|
+
}
|
|
225
|
+
get self() {
|
|
226
|
+
return this.#self;
|
|
227
|
+
}
|
|
228
|
+
async connect() {
|
|
229
|
+
this.#connected = true;
|
|
230
|
+
this.#self = SELF;
|
|
231
|
+
this.#events.emit("connection.status", { status: "open" });
|
|
232
|
+
}
|
|
233
|
+
async disconnect() {
|
|
234
|
+
this.#connected = false;
|
|
235
|
+
for (const connection of this.#connections) {
|
|
236
|
+
clearTimeout(connection.timer);
|
|
237
|
+
connection.closed = true;
|
|
238
|
+
connection.socket?.close(CLOSE_GOING_AWAY, "transport desconectado");
|
|
239
|
+
}
|
|
240
|
+
this.#connections.clear();
|
|
241
|
+
this.#chats.clear();
|
|
242
|
+
this.#outbox.clear();
|
|
243
|
+
this.#media.clear();
|
|
244
|
+
}
|
|
245
|
+
on(event, handler) {
|
|
246
|
+
return this.#events.on(event, handler);
|
|
247
|
+
}
|
|
248
|
+
async send(chatId, content, options) {
|
|
249
|
+
this.#assertConnected();
|
|
250
|
+
assertCanSend(this, content, options);
|
|
251
|
+
const frame = this.#messageFrame(content, options);
|
|
252
|
+
this.#deliver(chatId, frame);
|
|
253
|
+
return {
|
|
254
|
+
chatId,
|
|
255
|
+
id: frame.id,
|
|
256
|
+
fromMe: true,
|
|
257
|
+
senderId: null
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async react() {
|
|
261
|
+
throw new UnsupportedError("reactions", this.name);
|
|
262
|
+
}
|
|
263
|
+
async edit(key, text, formatted) {
|
|
264
|
+
this.#assertConnected();
|
|
265
|
+
this.#deliver(key.chatId, {
|
|
266
|
+
type: "edit",
|
|
267
|
+
id: key.id,
|
|
268
|
+
text,
|
|
269
|
+
...formatted === void 0 ? null : { formatted }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
async delete(key) {
|
|
273
|
+
this.#assertConnected();
|
|
274
|
+
this.#deliver(key.chatId, {
|
|
275
|
+
type: "delete",
|
|
276
|
+
id: key.id
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async sendTyping(chatId, kind) {
|
|
280
|
+
this.#assertConnected();
|
|
281
|
+
for (const connection of this.#chats.get(chatId) ?? []) this.#write(connection, {
|
|
282
|
+
type: "typing",
|
|
283
|
+
kind
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async getGroupMetadata() {
|
|
287
|
+
throw new UnsupportedError("groups", this.name);
|
|
288
|
+
}
|
|
289
|
+
async updateGroupParticipants(_groupId, _participantIds, action) {
|
|
290
|
+
throw new UnsupportedError(groupActionCapability(action), this.name);
|
|
291
|
+
}
|
|
292
|
+
#accept(request) {
|
|
293
|
+
if (!this.#connected) return new Response("transport desconectado", { status: 503 });
|
|
294
|
+
if (!this.#originAllowed(request)) return new Response("origem recusada", { status: 403 });
|
|
295
|
+
const connection = {
|
|
296
|
+
socket: null,
|
|
297
|
+
chat: null,
|
|
298
|
+
sender: null,
|
|
299
|
+
authenticating: false,
|
|
300
|
+
timer: void 0,
|
|
301
|
+
closed: false
|
|
302
|
+
};
|
|
303
|
+
return {
|
|
304
|
+
onOpen: (socket) => {
|
|
305
|
+
connection.socket = socket;
|
|
306
|
+
this.#connections.add(connection);
|
|
307
|
+
connection.timer = setTimeout(() => this.#close(connection, CLOSE_UNAUTHORIZED, "auth não chegou"), AUTH_TIMEOUT_MS);
|
|
308
|
+
},
|
|
309
|
+
onMessage: (_socket, data) => this.#onFrame(connection, data),
|
|
310
|
+
onClose: () => this.#forget(connection)
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async #onFrame(connection, data) {
|
|
314
|
+
if (connection.closed) return;
|
|
315
|
+
if ((typeof data === "string" ? Buffer.byteLength(data) : data.byteLength) > 65536) {
|
|
316
|
+
this.#close(connection, CLOSE_TOO_BIG, "frame grande demais");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const frame = typeof data === "string" ? parseJson(data) : void 0;
|
|
320
|
+
if (connection.chat === null) {
|
|
321
|
+
await this.#authenticate(connection, frame);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!isRecord(frame)) {
|
|
325
|
+
this.#reject(connection, "invalid-frame", "frame não é um objeto JSON");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (frame["type"] === "message") this.#onMessage(connection, frame);
|
|
329
|
+
else if (frame["type"] === "action") this.#onAction(connection, frame);
|
|
330
|
+
else this.#reject(connection, "invalid-frame", "tipo de frame desconhecido");
|
|
331
|
+
}
|
|
332
|
+
async #authenticate(connection, frame) {
|
|
333
|
+
if (connection.authenticating || !isRecord(frame) || frame["type"] !== "auth") {
|
|
334
|
+
this.#close(connection, CLOSE_INVALID_AUTH, "o primeiro frame deve ser `auth`");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const { token, conversation = DEFAULT_CONVERSATION } = frame;
|
|
338
|
+
if (typeof token !== "string" || typeof conversation !== "string" || !CONVERSATION_PATTERN.test(conversation)) {
|
|
339
|
+
this.#close(connection, CLOSE_INVALID_AUTH, "`auth` com token ou conversa inválidos");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
connection.authenticating = true;
|
|
343
|
+
let identity;
|
|
344
|
+
try {
|
|
345
|
+
identity = await this.#identify(token);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this.#refuse(connection, error);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (connection.closed) return;
|
|
351
|
+
const chatId = `${identity.contact.id}/${conversation}`;
|
|
352
|
+
connection.chat = {
|
|
353
|
+
id: chatId,
|
|
354
|
+
isGroup: false,
|
|
355
|
+
kind: "dm",
|
|
356
|
+
...identity.tenantId === void 0 ? null : { tenantId: identity.tenantId }
|
|
357
|
+
};
|
|
358
|
+
connection.sender = identity.contact;
|
|
359
|
+
connection.authenticating = false;
|
|
360
|
+
clearTimeout(connection.timer);
|
|
361
|
+
this.#expireAt(connection, identity.expiresAt);
|
|
362
|
+
let peers = this.#chats.get(chatId);
|
|
363
|
+
if (peers === void 0) {
|
|
364
|
+
peers = /* @__PURE__ */ new Set();
|
|
365
|
+
this.#chats.set(chatId, peers);
|
|
366
|
+
}
|
|
367
|
+
peers.add(connection);
|
|
368
|
+
this.#write(connection, {
|
|
369
|
+
type: "ready",
|
|
370
|
+
chatId,
|
|
371
|
+
userId: identity.contact.id
|
|
372
|
+
});
|
|
373
|
+
for (const pending of this.#outbox.drain(chatId)) this.#write(connection, pending);
|
|
374
|
+
}
|
|
375
|
+
#refuse(connection, error) {
|
|
376
|
+
if (error instanceof ForbiddenError) {
|
|
377
|
+
this.#log.debug("conexão recusada sem o claim de tenant", { reason: error.message });
|
|
378
|
+
this.#close(connection, CLOSE_FORBIDDEN, error.message);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (error instanceof InvalidTokenError) {
|
|
382
|
+
this.#log.debug("token recusado", { reason: error.message });
|
|
383
|
+
this.#close(connection, CLOSE_UNAUTHORIZED, "token inválido");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.#log.warn("não deu para verificar o token", { err: error });
|
|
387
|
+
this.#close(connection, CLOSE_UNAUTHORIZED, "não deu para verificar o token");
|
|
388
|
+
}
|
|
389
|
+
/** Fecha a conexão no `exp`; o cliente reconecta com um token novo (ADR 0077). */
|
|
390
|
+
#expireAt(connection, expiresAt) {
|
|
391
|
+
const remaining = expiresAt - Date.now();
|
|
392
|
+
if (remaining <= 0) {
|
|
393
|
+
this.#close(connection, CLOSE_UNAUTHORIZED, "token venceu");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
connection.timer = setTimeout(() => this.#expireAt(connection, expiresAt), Math.min(remaining, MAX_TIMER_MS));
|
|
397
|
+
}
|
|
398
|
+
#onMessage(connection, frame) {
|
|
399
|
+
const { ref, text, attachments = [] } = frame;
|
|
400
|
+
const chat = connection.chat;
|
|
401
|
+
const sender = connection.sender;
|
|
402
|
+
if (ref !== void 0 && (typeof ref !== "string" || ref.length > 128)) {
|
|
403
|
+
this.#reject(connection, "invalid-frame", "`ref` deve ser texto de até 128 caracteres");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (text !== void 0 && typeof text !== "string" || !Array.isArray(attachments) || attachments.length > 10 || !attachments.every((id) => typeof id === "string") || (text ?? "") === "" && attachments.length === 0) {
|
|
407
|
+
this.#reject(connection, "invalid-frame", `mensagem precisa de \`text\` ou de até 10 \`attachments\``, ref);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const media = this.#media.takeAll(attachments, sender.id);
|
|
411
|
+
if (media === void 0) {
|
|
412
|
+
this.#reject(connection, "media-not-found", "anexo inexistente, vencido ou de outro usuário", ref);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const id = randomUUID();
|
|
416
|
+
this.#write(connection, {
|
|
417
|
+
type: "ack",
|
|
418
|
+
ref: ref ?? null,
|
|
419
|
+
id
|
|
420
|
+
});
|
|
421
|
+
const base = {
|
|
422
|
+
id,
|
|
423
|
+
chat,
|
|
424
|
+
sender,
|
|
425
|
+
timestamp: Date.now(),
|
|
426
|
+
fromMe: false
|
|
427
|
+
};
|
|
428
|
+
const sources = media.map(toSource);
|
|
429
|
+
const first = sources[0];
|
|
430
|
+
const message = first === void 0 ? createMessage({
|
|
431
|
+
...base,
|
|
432
|
+
type: "text",
|
|
433
|
+
text
|
|
434
|
+
}) : createMessage({
|
|
435
|
+
...base,
|
|
436
|
+
...mediaType(first.mimetype, first.fileName),
|
|
437
|
+
text: text || null,
|
|
438
|
+
media: first,
|
|
439
|
+
attachments: sources
|
|
440
|
+
});
|
|
441
|
+
this.#events.emit("message", message);
|
|
442
|
+
}
|
|
443
|
+
#onAction(connection, frame) {
|
|
444
|
+
const { actionId } = frame;
|
|
445
|
+
if (typeof actionId !== "string" || actionId === "" || actionId.length > 64) {
|
|
446
|
+
this.#reject(connection, "invalid-frame", "`actionId` deve ser texto de até 64 caracteres");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.#events.emit("interaction", {
|
|
450
|
+
id: randomUUID(),
|
|
451
|
+
chat: connection.chat,
|
|
452
|
+
sender: connection.sender,
|
|
453
|
+
timestamp: Date.now(),
|
|
454
|
+
actionId
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
#forget(connection) {
|
|
458
|
+
clearTimeout(connection.timer);
|
|
459
|
+
connection.closed = true;
|
|
460
|
+
this.#connections.delete(connection);
|
|
461
|
+
const chatId = connection.chat?.id;
|
|
462
|
+
if (chatId === void 0) return;
|
|
463
|
+
const peers = this.#chats.get(chatId);
|
|
464
|
+
peers?.delete(connection);
|
|
465
|
+
if (peers?.size === 0) this.#chats.delete(chatId);
|
|
466
|
+
}
|
|
467
|
+
#close(connection, code, reason) {
|
|
468
|
+
if (connection.closed) return;
|
|
469
|
+
this.#forget(connection);
|
|
470
|
+
connection.socket?.close(code, reason);
|
|
471
|
+
}
|
|
472
|
+
#reject(connection, code, message, ref) {
|
|
473
|
+
this.#write(connection, {
|
|
474
|
+
type: "error",
|
|
475
|
+
code,
|
|
476
|
+
message,
|
|
477
|
+
...typeof ref === "string" ? { ref } : null
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
#write(connection, frame) {
|
|
481
|
+
connection.socket?.send(JSON.stringify(frame));
|
|
482
|
+
}
|
|
483
|
+
/** Para quem está na conversa, ou para o buffer se ninguém estiver. */
|
|
484
|
+
#deliver(chatId, frame) {
|
|
485
|
+
const peers = this.#chats.get(chatId);
|
|
486
|
+
if (peers === void 0) {
|
|
487
|
+
this.#outbox.push(chatId, frame);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
for (const connection of peers) this.#write(connection, frame);
|
|
491
|
+
}
|
|
492
|
+
#messageFrame(content, options) {
|
|
493
|
+
const common = {
|
|
494
|
+
type: "message",
|
|
495
|
+
id: randomUUID(),
|
|
496
|
+
timestamp: Date.now(),
|
|
497
|
+
...options?.quoted === void 0 ? null : { quotedId: options.quoted.id }
|
|
498
|
+
};
|
|
499
|
+
switch (content.type) {
|
|
500
|
+
case "text": return {
|
|
501
|
+
...common,
|
|
502
|
+
kind: "text",
|
|
503
|
+
text: content.text,
|
|
504
|
+
...content.formatted === void 0 ? null : { formatted: content.formatted },
|
|
505
|
+
...options?.actions === void 0 || options.actions.length === 0 ? null : { actions: options.actions.map(({ id, label }) => ({
|
|
506
|
+
id,
|
|
507
|
+
label
|
|
508
|
+
})) }
|
|
509
|
+
};
|
|
510
|
+
case "image":
|
|
511
|
+
case "video":
|
|
512
|
+
case "audio":
|
|
513
|
+
case "document": {
|
|
514
|
+
const caption = "caption" in content ? content.caption : void 0;
|
|
515
|
+
const formatted = "formattedCaption" in content ? content.formattedCaption : void 0;
|
|
516
|
+
return {
|
|
517
|
+
...common,
|
|
518
|
+
kind: content.type,
|
|
519
|
+
media: this.#mediaRef(content),
|
|
520
|
+
...caption === void 0 ? null : { text: caption },
|
|
521
|
+
...formatted === void 0 ? null : { formatted }
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
default: throw new UnsupportedError("send.album", this.name);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
#mediaRef(content) {
|
|
528
|
+
const named = content.fileName === void 0 ? null : { fileName: content.fileName };
|
|
529
|
+
const mimetype = content.mimetype ?? "application/octet-stream";
|
|
530
|
+
if (!Buffer.isBuffer(content.media)) return {
|
|
531
|
+
url: content.media.url,
|
|
532
|
+
mimetype,
|
|
533
|
+
...named
|
|
534
|
+
};
|
|
535
|
+
const id = this.#media.put({
|
|
536
|
+
bytes: content.media,
|
|
537
|
+
mimetype,
|
|
538
|
+
...named
|
|
539
|
+
});
|
|
540
|
+
return {
|
|
541
|
+
url: `${this.#routes.basePath}/media/${id}`,
|
|
542
|
+
mimetype,
|
|
543
|
+
...named
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
async #upload(request) {
|
|
547
|
+
if (!this.#connected) return new Response("transport desconectado", { status: 503 });
|
|
548
|
+
const cors = this.#cors(request);
|
|
549
|
+
if (cors === null) return new Response("origem recusada", { status: 403 });
|
|
550
|
+
const token = bearerToken(request);
|
|
551
|
+
if (token === null) return text("falta o header Authorization: Bearer", 401, cors);
|
|
552
|
+
let identity;
|
|
553
|
+
try {
|
|
554
|
+
identity = await this.#identify(token);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (error instanceof ForbiddenError) return text(error.message, 403, cors);
|
|
557
|
+
if (error instanceof InvalidTokenError) return text("token inválido", 401, cors);
|
|
558
|
+
this.#log.warn("não deu para verificar o token do upload", { err: error });
|
|
559
|
+
return text("não deu para verificar o token", 503, cors);
|
|
560
|
+
}
|
|
561
|
+
const mimetype = request.headers.get("content-type")?.split(";")[0]?.trim();
|
|
562
|
+
if (!mimetype) return text("falta o Content-Type", 415, cors);
|
|
563
|
+
if (Number(request.headers.get("content-length") ?? 0) > this.#maxBytes) return text("arquivo grande demais", 413, cors);
|
|
564
|
+
const bytes = await readLimited(request, this.#maxBytes);
|
|
565
|
+
if (bytes === null) return text("arquivo grande demais", 413, cors);
|
|
566
|
+
if (bytes.length === 0) return text("arquivo vazio", 400, cors);
|
|
567
|
+
const name = new URL(request.url).searchParams.get("name");
|
|
568
|
+
const id = this.#media.put({
|
|
569
|
+
bytes,
|
|
570
|
+
mimetype,
|
|
571
|
+
owner: identity.contact.id,
|
|
572
|
+
...name ? { fileName: name.slice(0, 255) } : null
|
|
573
|
+
});
|
|
574
|
+
return Response.json({ id }, {
|
|
575
|
+
status: 201,
|
|
576
|
+
headers: cors
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
#preflight(request) {
|
|
580
|
+
const cors = this.#cors(request);
|
|
581
|
+
if (cors === null) return new Response("origem recusada", { status: 403 });
|
|
582
|
+
return new Response(null, {
|
|
583
|
+
status: 204,
|
|
584
|
+
headers: {
|
|
585
|
+
...cors,
|
|
586
|
+
"access-control-allow-methods": "POST",
|
|
587
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
588
|
+
"access-control-max-age": "600"
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
#download(request, id) {
|
|
593
|
+
const cors = this.#cors(request) ?? {};
|
|
594
|
+
const media = this.#media.get(id);
|
|
595
|
+
if (media === void 0 || media.owner !== void 0) return text("não encontrado", 404, cors);
|
|
596
|
+
const inline = /^(image|video|audio)\//.test(media.mimetype);
|
|
597
|
+
const name = media.fileName === void 0 ? "" : `; filename*=UTF-8''${encodeURIComponent(media.fileName)}`;
|
|
598
|
+
return new Response(media.bytes, { headers: {
|
|
599
|
+
...cors,
|
|
600
|
+
"content-type": media.mimetype,
|
|
601
|
+
"content-disposition": `${inline ? "inline" : "attachment"}${name}`,
|
|
602
|
+
"x-content-type-options": "nosniff",
|
|
603
|
+
"content-security-policy": "sandbox",
|
|
604
|
+
"cache-control": "private, max-age=3600"
|
|
605
|
+
} });
|
|
606
|
+
}
|
|
607
|
+
async #identify(token) {
|
|
608
|
+
const verified = await this.#verify(token);
|
|
609
|
+
const claim = this.#options.tenantClaim;
|
|
610
|
+
let tenantId;
|
|
611
|
+
if (claim !== void 0) {
|
|
612
|
+
const value = verified.claims[claim];
|
|
613
|
+
if (typeof value !== "string" && typeof value !== "number" || value === "") throw new ForbiddenError(`token sem o claim de tenant "${claim}"`);
|
|
614
|
+
tenantId = String(value);
|
|
615
|
+
}
|
|
616
|
+
const sub = encodeURIComponent(verified.sub);
|
|
617
|
+
const id = tenantId === void 0 ? sub : `${encodeURIComponent(tenantId)}:${sub}`;
|
|
618
|
+
const name = verified.claims["name"];
|
|
619
|
+
return {
|
|
620
|
+
contact: {
|
|
621
|
+
id,
|
|
622
|
+
name: typeof name === "string" ? name : null,
|
|
623
|
+
phone: null,
|
|
624
|
+
claims: verified.claims
|
|
625
|
+
},
|
|
626
|
+
...tenantId === void 0 ? null : { tenantId },
|
|
627
|
+
expiresAt: verified.expiresAt
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
#originAllowed(request) {
|
|
631
|
+
const origin = request.headers.get("origin");
|
|
632
|
+
return origin === null || this.#options.origins === void 0 || this.#options.origins.includes(origin);
|
|
633
|
+
}
|
|
634
|
+
/** Headers de CORS para a origem do pedido; `null` se ela não é aceita. */
|
|
635
|
+
#cors(request) {
|
|
636
|
+
if (!this.#originAllowed(request)) return null;
|
|
637
|
+
const origin = request.headers.get("origin");
|
|
638
|
+
if (origin === null) return {};
|
|
639
|
+
return this.#options.origins === void 0 ? { "access-control-allow-origin": "*" } : {
|
|
640
|
+
"access-control-allow-origin": origin,
|
|
641
|
+
vary: "Origin"
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
#assertConnected() {
|
|
645
|
+
if (!this.#connected) throw new Error("web: transport desconectado");
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
function parseJson(data) {
|
|
649
|
+
try {
|
|
650
|
+
return JSON.parse(data);
|
|
651
|
+
} catch {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function isRecord(value) {
|
|
656
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
657
|
+
}
|
|
658
|
+
function text(body, status, headers) {
|
|
659
|
+
return new Response(body, {
|
|
660
|
+
status,
|
|
661
|
+
headers
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/** Corpo inteiro, ou `null` se passar de `max` (o `Content-Length` pode mentir ou faltar). */
|
|
665
|
+
async function readLimited(request, max) {
|
|
666
|
+
if (request.body === null) return Buffer.alloc(0);
|
|
667
|
+
const chunks = [];
|
|
668
|
+
let total = 0;
|
|
669
|
+
for await (const chunk of request.body) {
|
|
670
|
+
total += chunk.byteLength;
|
|
671
|
+
if (total > max) return null;
|
|
672
|
+
chunks.push(chunk);
|
|
673
|
+
}
|
|
674
|
+
return Buffer.concat(chunks);
|
|
675
|
+
}
|
|
676
|
+
function toSource(media) {
|
|
677
|
+
return {
|
|
678
|
+
mimetype: media.mimetype,
|
|
679
|
+
size: media.bytes.length,
|
|
680
|
+
...media.fileName === void 0 ? null : { fileName: media.fileName },
|
|
681
|
+
download: async () => media.bytes
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/** Tipo da mensagem pelo mimetype do primeiro anexo (ADR 0065). */
|
|
685
|
+
function mediaType(mimetype, fileName) {
|
|
686
|
+
if (mimetype.startsWith("image/")) return { type: "image" };
|
|
687
|
+
if (mimetype.startsWith("video/")) return { type: "video" };
|
|
688
|
+
if (mimetype.startsWith("audio/")) return { type: "audio" };
|
|
689
|
+
return {
|
|
690
|
+
type: "document",
|
|
691
|
+
fileName: fileName ?? null
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
//#endregion
|
|
695
|
+
//#region src/index.ts
|
|
696
|
+
function web(options) {
|
|
697
|
+
const verify = createVerifier(options.auth);
|
|
698
|
+
const maxBytes = options.media?.maxBytes;
|
|
699
|
+
if (maxBytes !== void 0 && (!Number.isInteger(maxBytes) || maxBytes <= 0)) throw new RangeError(`web: media.maxBytes deve ser inteiro positivo; recebido ${maxBytes}`);
|
|
700
|
+
if (options.tenantClaim === "") throw new TypeError("web: tenantClaim vazio");
|
|
701
|
+
return (deps) => {
|
|
702
|
+
if (deps.http === void 0) throw new TypeError("web: o transport precisa de `http` no createBot (ver createHttp)");
|
|
703
|
+
return new WebTransport(options, verify, deps.http, deps.log);
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
//#endregion
|
|
707
|
+
export { AUTH_TIMEOUT_MS, CLOSE_FORBIDDEN, CLOSE_GOING_AWAY, CLOSE_INVALID_AUTH, CLOSE_TOO_BIG, CLOSE_UNAUTHORIZED, CONVERSATION_PATTERN, DEFAULT_CONVERSATION, DEFAULT_MEDIA_MAX_BYTES, MAX_ATTACHMENTS, MAX_FRAME_BYTES, web };
|
|
708
|
+
|
|
709
|
+
//# sourceMappingURL=index.mjs.map
|