misterbot 0.1.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 +160 -0
- package/dist/index.cjs +343 -0
- package/dist/index.d.cts +190 -0
- package/dist/index.d.ts +190 -0
- package/dist/index.js +303 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# misterbot
|
|
2
|
+
|
|
3
|
+
SDK oficial de **Mister Bot** — el gateway HTTP que expone WhatsApp (vía Baileys) a tus apps.
|
|
4
|
+
|
|
5
|
+
Envía mensajes de texto y multimedia que el servidor encola con jitter anti-ban, y trae un flujo listo para **validar números de celular por código (OTP)**.
|
|
6
|
+
|
|
7
|
+
> Necesitás un token de API (`mbk_…`) que crea un admin desde el panel de Mister Bot, y la URL base de tu instancia.
|
|
8
|
+
|
|
9
|
+
## Instalación
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install misterbot
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requiere Node >= 18 (usa `fetch`, `FormData` y `Blob` globales). En el browser también funciona si pasás un token con CORS habilitado.
|
|
16
|
+
|
|
17
|
+
## Uso rápido
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { MisterBotClient } from 'misterbot'
|
|
21
|
+
|
|
22
|
+
const mb = new MisterBotClient({
|
|
23
|
+
baseUrl: 'https://wa.miempresa.com',
|
|
24
|
+
token: 'mbk_xxxxxxxxxxxxxxxx',
|
|
25
|
+
sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', // opcional si el token tiene sesión fija
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const { id } = await mb.sendText({
|
|
29
|
+
to: '5491133334444',
|
|
30
|
+
text: '¡Hola! Probando la API.',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const estado = await mb.getStatus(id)
|
|
34
|
+
console.log(estado.status) // 'sent' | 'delivered' | 'read' | ...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`sendText`/`sendMedia` resuelven cuando el servidor **despachó** el mensaje por la cola anti-ban; por eso un envío puede tardar (delays aleatorios entre mensajes). Ajustá `timeoutMs` si hace falta.
|
|
38
|
+
|
|
39
|
+
## Enviar multimedia
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// Desde datos binarios (Buffer, Uint8Array, ArrayBuffer o Blob)
|
|
43
|
+
await mb.sendMedia({
|
|
44
|
+
to: '5491133334444',
|
|
45
|
+
data: miBuffer,
|
|
46
|
+
mimeType: 'image/jpeg',
|
|
47
|
+
filename: 'foto.jpg',
|
|
48
|
+
caption: 'Mirá esto',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Conveniencia en Node: directo desde un archivo del disco
|
|
52
|
+
await mb.sendMediaFile({
|
|
53
|
+
to: '5491133334444',
|
|
54
|
+
path: './facturas/0001.pdf',
|
|
55
|
+
caption: 'Tu factura',
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Validación de número por código (OTP)
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const otp = mb.otp({
|
|
63
|
+
ttlMs: 5 * 60 * 1000, // vigencia del código (default 5 min)
|
|
64
|
+
codeLength: 6, // dígitos (default 6)
|
|
65
|
+
maxAttempts: 3, // intentos antes de invalidar (default 3)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// 1. Mandar el código
|
|
69
|
+
await otp.send('5491133334444')
|
|
70
|
+
|
|
71
|
+
// 2. Verificar lo que ingresó el usuario
|
|
72
|
+
const r = await otp.verify('5491133334444', '123456')
|
|
73
|
+
if (r.ok) {
|
|
74
|
+
// número validado
|
|
75
|
+
} else {
|
|
76
|
+
// r.reason: 'mismatch' | 'expired' | 'not_found' | 'too_many_attempts'
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Store de códigos
|
|
81
|
+
|
|
82
|
+
Por defecto el estado vive **en memoria** (`MemoryCodeStore`), suficiente para un solo proceso. Si corrés varias instancias o querés que sobreviva reinicios, inyectá tu propio store (Redis, DB, etc.):
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { MisterBotClient, type CodeStore } from 'misterbot'
|
|
86
|
+
|
|
87
|
+
const redisStore: CodeStore = {
|
|
88
|
+
async get(key) {
|
|
89
|
+
const raw = await redis.get(`otp:${key}`)
|
|
90
|
+
return raw ? JSON.parse(raw) : undefined
|
|
91
|
+
},
|
|
92
|
+
async set(key, value) {
|
|
93
|
+
await redis.set(`otp:${key}`, JSON.stringify(value), 'PX', value.expiresAt - Date.now())
|
|
94
|
+
},
|
|
95
|
+
async delete(key) {
|
|
96
|
+
await redis.del(`otp:${key}`)
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const otp = mb.otp({ store: redisStore })
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Mensaje personalizado
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const otp = mb.otp({
|
|
107
|
+
template: (code, ttlMs) =>
|
|
108
|
+
`Tu código MiApp es ${code}. Vence en ${Math.round(ttlMs / 60000)} min.`,
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Esperar confirmación de envío
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const { id } = await mb.sendText({ to, text })
|
|
116
|
+
const final = await mb.waitUntilSent(id, { minStatus: 'delivered', timeoutMs: 30_000 })
|
|
117
|
+
console.log(final.status)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Manejo de errores
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { MisterBotError } from 'misterbot'
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await mb.sendText({ to, text })
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err instanceof MisterBotError) {
|
|
129
|
+
if (err.isQuotaError) {
|
|
130
|
+
// cuota agotada: NO reintentar en loop, esperá al día siguiente
|
|
131
|
+
} else if (err.isRetryable) {
|
|
132
|
+
// 5xx: reintentá con backoff
|
|
133
|
+
}
|
|
134
|
+
console.error(err.code, err.status) // ej: 'session_not_connected', 409
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Códigos comunes: `invalid_token` (401), `session_not_connected` (409), `session_not_found` (404), `daily_quota_exceeded` / `TOTAL_QUOTA_EXCEEDED` (429).
|
|
140
|
+
|
|
141
|
+
## API
|
|
142
|
+
|
|
143
|
+
| Método | Descripción |
|
|
144
|
+
|--------|-------------|
|
|
145
|
+
| `new MisterBotClient(opts)` | `baseUrl`, `token`, `sessionId?`, `timeoutMs?`, `fetch?` |
|
|
146
|
+
| `sendText({ to, text, sessionId? })` | Envía texto. Devuelve `{ id }`. |
|
|
147
|
+
| `sendMedia({ to, data, mimeType, filename?, caption?, sessionId? })` | Envía binario. |
|
|
148
|
+
| `sendMediaFile({ to, path, mimeType?, caption?, sessionId? })` | Lee y envía un archivo (Node). |
|
|
149
|
+
| `getStatus(id)` | Estado del mensaje. |
|
|
150
|
+
| `listSessions()` | Sesiones que el token puede usar. |
|
|
151
|
+
| `waitUntilSent(id, opts?)` | Pollea hasta estado terminal. |
|
|
152
|
+
| `otp(opts?)` | Crea un `OtpManager` (`send` / `verify`). |
|
|
153
|
+
|
|
154
|
+
## Desarrollo
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm install
|
|
158
|
+
npm run build # genera dist/ (ESM + CJS + tipos) con tsup
|
|
159
|
+
npm run typecheck
|
|
160
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
MemoryCodeStore: () => MemoryCodeStore,
|
|
34
|
+
MisterBotClient: () => MisterBotClient,
|
|
35
|
+
MisterBotError: () => MisterBotError,
|
|
36
|
+
OtpManager: () => OtpManager
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(index_exports);
|
|
39
|
+
|
|
40
|
+
// src/errors.ts
|
|
41
|
+
var QUOTA_CODES = /* @__PURE__ */ new Set([
|
|
42
|
+
"daily_quota_exceeded",
|
|
43
|
+
"monthly_quota_exceeded",
|
|
44
|
+
"UNKNOWN_QUOTA_EXCEEDED",
|
|
45
|
+
"TOTAL_QUOTA_EXCEEDED"
|
|
46
|
+
]);
|
|
47
|
+
var MisterBotError = class extends Error {
|
|
48
|
+
status;
|
|
49
|
+
code;
|
|
50
|
+
constructor(status, code, message) {
|
|
51
|
+
super(message ?? code);
|
|
52
|
+
this.name = "MisterBotError";
|
|
53
|
+
this.status = status;
|
|
54
|
+
this.code = code;
|
|
55
|
+
}
|
|
56
|
+
/** true si el envío fue rechazado por cuota (token o anti-ban). No reintentar en loop. */
|
|
57
|
+
get isQuotaError() {
|
|
58
|
+
return QUOTA_CODES.has(this.code);
|
|
59
|
+
}
|
|
60
|
+
/** true si conviene reintentar con backoff (5xx). Los 4xx no se reintentan. */
|
|
61
|
+
get isRetryable() {
|
|
62
|
+
return this.status >= 500;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/store.ts
|
|
67
|
+
var MemoryCodeStore = class {
|
|
68
|
+
map = /* @__PURE__ */ new Map();
|
|
69
|
+
get(key) {
|
|
70
|
+
const v = this.map.get(key);
|
|
71
|
+
if (!v) return void 0;
|
|
72
|
+
if (v.expiresAt <= Date.now()) {
|
|
73
|
+
this.map.delete(key);
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
set(key, value) {
|
|
79
|
+
this.map.set(key, value);
|
|
80
|
+
}
|
|
81
|
+
delete(key) {
|
|
82
|
+
this.map.delete(key);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// src/otp.ts
|
|
87
|
+
function defaultTemplate(code, ttlMs) {
|
|
88
|
+
const mins = Math.max(1, Math.round(ttlMs / 6e4));
|
|
89
|
+
return `Tu c\xF3digo de verificaci\xF3n es: ${code}
|
|
90
|
+
|
|
91
|
+
Vence en ${mins} minuto${mins === 1 ? "" : "s"}. No lo compartas con nadie.`;
|
|
92
|
+
}
|
|
93
|
+
function randomDigits(length) {
|
|
94
|
+
const g = globalThis;
|
|
95
|
+
let out = "";
|
|
96
|
+
if (g.crypto?.getRandomValues) {
|
|
97
|
+
const buf = new Uint32Array(length);
|
|
98
|
+
g.crypto.getRandomValues(buf);
|
|
99
|
+
for (let i = 0; i < length; i++) out += String((buf[i] ?? 0) % 10);
|
|
100
|
+
} else {
|
|
101
|
+
for (let i = 0; i < length; i++) out += String(Math.floor(Math.random() * 10));
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function timingSafeEqual(a, b) {
|
|
106
|
+
if (a.length !== b.length) return false;
|
|
107
|
+
let diff = 0;
|
|
108
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
109
|
+
return diff === 0;
|
|
110
|
+
}
|
|
111
|
+
function normalizePhone(phone) {
|
|
112
|
+
return phone.replace(/[^0-9]/g, "");
|
|
113
|
+
}
|
|
114
|
+
var OtpManager = class {
|
|
115
|
+
sender;
|
|
116
|
+
store;
|
|
117
|
+
codeLength;
|
|
118
|
+
ttlMs;
|
|
119
|
+
maxAttempts;
|
|
120
|
+
sessionId;
|
|
121
|
+
template;
|
|
122
|
+
generate;
|
|
123
|
+
constructor(sender, opts = {}) {
|
|
124
|
+
this.sender = sender;
|
|
125
|
+
this.store = opts.store ?? new MemoryCodeStore();
|
|
126
|
+
this.codeLength = opts.codeLength ?? 6;
|
|
127
|
+
this.ttlMs = opts.ttlMs ?? 5 * 60 * 1e3;
|
|
128
|
+
this.maxAttempts = opts.maxAttempts ?? 3;
|
|
129
|
+
this.sessionId = opts.sessionId;
|
|
130
|
+
this.template = opts.template ?? defaultTemplate;
|
|
131
|
+
this.generate = opts.generateCode ?? (() => randomDigits(this.codeLength));
|
|
132
|
+
}
|
|
133
|
+
/** Genera y envía un código a `phone`. Reemplaza cualquier código previo del mismo número. */
|
|
134
|
+
async send(phone) {
|
|
135
|
+
const key = normalizePhone(phone);
|
|
136
|
+
const code = this.generate();
|
|
137
|
+
const expiresAt = Date.now() + this.ttlMs;
|
|
138
|
+
await this.store.set(key, { code, expiresAt, attempts: 0 });
|
|
139
|
+
const result = await this.sender.sendText({
|
|
140
|
+
to: key,
|
|
141
|
+
text: this.template(code, this.ttlMs),
|
|
142
|
+
sessionId: this.sessionId
|
|
143
|
+
});
|
|
144
|
+
return { id: result.id, expiresAt };
|
|
145
|
+
}
|
|
146
|
+
/** Verifica el código ingresado por el usuario. Consume el código si es correcto. */
|
|
147
|
+
async verify(phone, code) {
|
|
148
|
+
const key = normalizePhone(phone);
|
|
149
|
+
const entry = await this.store.get(key);
|
|
150
|
+
if (!entry) return { ok: false, reason: "not_found" };
|
|
151
|
+
if (entry.expiresAt <= Date.now()) {
|
|
152
|
+
await this.store.delete(key);
|
|
153
|
+
return { ok: false, reason: "expired" };
|
|
154
|
+
}
|
|
155
|
+
if (entry.attempts >= this.maxAttempts) {
|
|
156
|
+
await this.store.delete(key);
|
|
157
|
+
return { ok: false, reason: "too_many_attempts" };
|
|
158
|
+
}
|
|
159
|
+
if (timingSafeEqual(entry.code, code.trim())) {
|
|
160
|
+
await this.store.delete(key);
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
const attempts = entry.attempts + 1;
|
|
164
|
+
if (attempts >= this.maxAttempts) {
|
|
165
|
+
await this.store.delete(key);
|
|
166
|
+
return { ok: false, reason: "too_many_attempts" };
|
|
167
|
+
}
|
|
168
|
+
await this.store.set(key, { ...entry, attempts });
|
|
169
|
+
return { ok: false, reason: "mismatch" };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// src/client.ts
|
|
174
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
175
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["sent", "delivered", "read", "failed"]);
|
|
176
|
+
var EXT_MIME = {
|
|
177
|
+
jpg: "image/jpeg",
|
|
178
|
+
jpeg: "image/jpeg",
|
|
179
|
+
png: "image/png",
|
|
180
|
+
webp: "image/webp",
|
|
181
|
+
gif: "image/gif",
|
|
182
|
+
mp4: "video/mp4",
|
|
183
|
+
"3gp": "video/3gpp",
|
|
184
|
+
webm: "video/webm",
|
|
185
|
+
ogg: "audio/ogg",
|
|
186
|
+
mp3: "audio/mpeg",
|
|
187
|
+
m4a: "audio/mp4",
|
|
188
|
+
aac: "audio/aac",
|
|
189
|
+
pdf: "application/pdf",
|
|
190
|
+
zip: "application/zip",
|
|
191
|
+
txt: "text/plain",
|
|
192
|
+
csv: "text/csv"
|
|
193
|
+
};
|
|
194
|
+
var MisterBotClient = class {
|
|
195
|
+
baseUrl;
|
|
196
|
+
token;
|
|
197
|
+
sessionId;
|
|
198
|
+
timeoutMs;
|
|
199
|
+
fetchImpl;
|
|
200
|
+
constructor(opts) {
|
|
201
|
+
if (!opts.baseUrl) throw new Error("baseUrl is required");
|
|
202
|
+
if (!opts.token) throw new Error("token is required");
|
|
203
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
204
|
+
this.token = opts.token;
|
|
205
|
+
this.sessionId = opts.sessionId;
|
|
206
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
207
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
208
|
+
if (!f) throw new Error("No fetch available \u2014 pass options.fetch (Node <18 needs a polyfill)");
|
|
209
|
+
this.fetchImpl = f;
|
|
210
|
+
}
|
|
211
|
+
/** Envía un mensaje de texto. Resuelve cuando el servidor lo despachó por la cola anti-ban. */
|
|
212
|
+
async sendText(input) {
|
|
213
|
+
const body = {
|
|
214
|
+
to: input.to,
|
|
215
|
+
text: input.text,
|
|
216
|
+
sessionId: input.sessionId ?? this.sessionId
|
|
217
|
+
};
|
|
218
|
+
const res = await this.request("POST", "/api/v1/send/text", {
|
|
219
|
+
headers: { "content-type": "application/json" },
|
|
220
|
+
body: JSON.stringify(body)
|
|
221
|
+
});
|
|
222
|
+
return { id: res.id };
|
|
223
|
+
}
|
|
224
|
+
/** Envía un archivo (imagen/video/audio/documento) a partir de datos binarios. */
|
|
225
|
+
async sendMedia(input) {
|
|
226
|
+
const blob = toBlob(input.data, input.mimeType);
|
|
227
|
+
const form = new FormData();
|
|
228
|
+
form.append("file", blob, input.filename ?? "file");
|
|
229
|
+
form.append("to", input.to);
|
|
230
|
+
if (input.caption) form.append("caption", input.caption);
|
|
231
|
+
const sessionId = input.sessionId ?? this.sessionId;
|
|
232
|
+
if (sessionId) form.append("sessionId", sessionId);
|
|
233
|
+
const res = await this.request("POST", "/api/v1/send/media", { body: form });
|
|
234
|
+
return { id: res.id };
|
|
235
|
+
}
|
|
236
|
+
/** Conveniencia para Node: lee un archivo del disco y lo envía. */
|
|
237
|
+
async sendMediaFile(input) {
|
|
238
|
+
const { readFile } = await import("fs/promises");
|
|
239
|
+
const data = await readFile(input.path);
|
|
240
|
+
const mimeType = input.mimeType ?? inferMime(input.path);
|
|
241
|
+
if (!mimeType) throw new Error(`No se pudo inferir el MIME de "${input.path}" \u2014 pas\xE1 mimeType.`);
|
|
242
|
+
const filename = input.path.split(/[\\/]/).pop() ?? "file";
|
|
243
|
+
return this.sendMedia({
|
|
244
|
+
to: input.to,
|
|
245
|
+
data: new Uint8Array(data),
|
|
246
|
+
mimeType,
|
|
247
|
+
filename,
|
|
248
|
+
caption: input.caption,
|
|
249
|
+
sessionId: input.sessionId
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** Estado actual de un mensaje (queued/sending/sent/delivered/read/failed). */
|
|
253
|
+
async getStatus(id) {
|
|
254
|
+
const res = await this.request("GET", `/api/v1/status/${encodeURIComponent(id)}`);
|
|
255
|
+
return res;
|
|
256
|
+
}
|
|
257
|
+
/** Sesiones que el token puede usar. */
|
|
258
|
+
async listSessions() {
|
|
259
|
+
const res = await this.request("GET", "/api/v1/sessions");
|
|
260
|
+
return res.sessions ?? [];
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Pollea getStatus() hasta que el mensaje llegue a un estado terminal
|
|
264
|
+
* (sent/delivered/read/failed) o se agote el tiempo. Backoff: 1s,2s,3s,5s,8s...
|
|
265
|
+
*/
|
|
266
|
+
async waitUntilSent(id, opts = {}) {
|
|
267
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
|
|
268
|
+
const target = opts.minStatus ?? "sent";
|
|
269
|
+
const rank = { queued: 0, sending: 1, sent: 2, delivered: 3, read: 4 };
|
|
270
|
+
const delays = [1e3, 2e3, 3e3, 5e3, 8e3];
|
|
271
|
+
let attempt = 0;
|
|
272
|
+
for (; ; ) {
|
|
273
|
+
const s = await this.getStatus(id);
|
|
274
|
+
if (s.status === "failed") return s;
|
|
275
|
+
if ((rank[s.status] ?? 0) >= (rank[target] ?? 2)) return s;
|
|
276
|
+
if (!TERMINAL_STATUSES.has(s.status) && Date.now() >= deadline) return s;
|
|
277
|
+
const wait = delays[Math.min(attempt, delays.length - 1)] ?? 8e3;
|
|
278
|
+
if (Date.now() + wait > deadline) return s;
|
|
279
|
+
await sleep(wait);
|
|
280
|
+
attempt++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Crea un manejador de OTP (validación de número por código) atado a este cliente. */
|
|
284
|
+
otp(opts = {}) {
|
|
285
|
+
return new OtpManager(this, { sessionId: this.sessionId, ...opts });
|
|
286
|
+
}
|
|
287
|
+
async request(method, path, init = {}) {
|
|
288
|
+
const controller = new AbortController();
|
|
289
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
290
|
+
let res;
|
|
291
|
+
try {
|
|
292
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
293
|
+
method,
|
|
294
|
+
headers: { authorization: `Bearer ${this.token}`, ...init.headers },
|
|
295
|
+
body: init.body,
|
|
296
|
+
signal: controller.signal
|
|
297
|
+
});
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (err?.name === "AbortError") {
|
|
300
|
+
throw new MisterBotError(408, "request_timeout", `La request super\xF3 ${this.timeoutMs}ms`);
|
|
301
|
+
}
|
|
302
|
+
throw new MisterBotError(0, "network_error", String(err?.message ?? err));
|
|
303
|
+
} finally {
|
|
304
|
+
clearTimeout(timer);
|
|
305
|
+
}
|
|
306
|
+
const text = await res.text();
|
|
307
|
+
let json = {};
|
|
308
|
+
if (text) {
|
|
309
|
+
try {
|
|
310
|
+
json = JSON.parse(text);
|
|
311
|
+
} catch {
|
|
312
|
+
json = {};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
const code = json.statusMessage || json.message || `http_${res.status}`;
|
|
317
|
+
throw new MisterBotError(res.status, code, json.message || code);
|
|
318
|
+
}
|
|
319
|
+
return json;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
function toBlob(data, mimeType) {
|
|
323
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) return data;
|
|
324
|
+
if (data instanceof ArrayBuffer) return new Blob([data], { type: mimeType });
|
|
325
|
+
if (ArrayBuffer.isView(data)) {
|
|
326
|
+
return new Blob([data], { type: mimeType });
|
|
327
|
+
}
|
|
328
|
+
throw new Error("data debe ser Blob, ArrayBuffer o ArrayBufferView (Buffer/Uint8Array)");
|
|
329
|
+
}
|
|
330
|
+
function inferMime(path) {
|
|
331
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
332
|
+
return EXT_MIME[ext];
|
|
333
|
+
}
|
|
334
|
+
function sleep(ms) {
|
|
335
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
336
|
+
}
|
|
337
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
338
|
+
0 && (module.exports = {
|
|
339
|
+
MemoryCodeStore,
|
|
340
|
+
MisterBotClient,
|
|
341
|
+
MisterBotError,
|
|
342
|
+
OtpManager
|
|
343
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
interface MisterBotClientOptions {
|
|
2
|
+
/** Base URL del servidor Mister Bot, ej: "https://wa.miempresa.com". Sin "/" final. */
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Token de API (empieza con "mbk_"). Lo crea un admin desde el panel. */
|
|
5
|
+
token: string;
|
|
6
|
+
/** Sesión por defecto. Opcional si el token ya tiene una sesión fija. */
|
|
7
|
+
sessionId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Timeout por request en ms. El servidor encola el envío y responde recién
|
|
10
|
+
* cuando el mensaje salió, así que un envío puede tardar (jitter anti-ban).
|
|
11
|
+
* Default: 120000 (2 min).
|
|
12
|
+
*/
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Implementación de fetch a usar. Default: el `fetch` global (Node >=18 / browser). */
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
interface SendTextInput {
|
|
18
|
+
/** Número internacional sin "+" ni espacios. Ej: "5491133334444". */
|
|
19
|
+
to: string;
|
|
20
|
+
/** Texto del mensaje (1..4096 chars). */
|
|
21
|
+
text: string;
|
|
22
|
+
/** Override de la sesión para este envío. */
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
}
|
|
25
|
+
type MediaData = Blob | ArrayBuffer | ArrayBufferView;
|
|
26
|
+
interface SendMediaInput {
|
|
27
|
+
to: string;
|
|
28
|
+
/** Contenido binario del archivo (Blob, Buffer, Uint8Array, ArrayBuffer). */
|
|
29
|
+
data: MediaData;
|
|
30
|
+
/** MIME del archivo, ej: "image/jpeg", "application/pdf". */
|
|
31
|
+
mimeType: string;
|
|
32
|
+
/** Nombre de archivo (recomendado para documentos). */
|
|
33
|
+
filename?: string;
|
|
34
|
+
/** Leyenda (solo imagen/video/documento). */
|
|
35
|
+
caption?: string;
|
|
36
|
+
sessionId?: string;
|
|
37
|
+
}
|
|
38
|
+
interface SendMediaFileInput {
|
|
39
|
+
to: string;
|
|
40
|
+
/** Ruta en disco del archivo a enviar (solo Node). */
|
|
41
|
+
path: string;
|
|
42
|
+
/** MIME explícito. Si se omite se infiere de la extensión. */
|
|
43
|
+
mimeType?: string;
|
|
44
|
+
caption?: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
}
|
|
47
|
+
interface SendResult {
|
|
48
|
+
/** UUID del mensaje. Usalo con getStatus() / waitUntilSent(). */
|
|
49
|
+
id: string;
|
|
50
|
+
}
|
|
51
|
+
type MessageStatusValue = 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'received';
|
|
52
|
+
interface MessageStatus {
|
|
53
|
+
id: string;
|
|
54
|
+
status: MessageStatusValue;
|
|
55
|
+
type: string;
|
|
56
|
+
to: string;
|
|
57
|
+
sentAt: string | null;
|
|
58
|
+
deliveredAt: string | null;
|
|
59
|
+
readAt: string | null;
|
|
60
|
+
failedAt: string | null;
|
|
61
|
+
error: string | null;
|
|
62
|
+
}
|
|
63
|
+
interface Session {
|
|
64
|
+
id: string;
|
|
65
|
+
name: string;
|
|
66
|
+
phoneNumber: string | null;
|
|
67
|
+
status: string;
|
|
68
|
+
isPaused: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface StoredCode {
|
|
72
|
+
code: string;
|
|
73
|
+
expiresAt: number;
|
|
74
|
+
attempts: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Almacén de códigos OTP. Por defecto el SDK usa uno en memoria, pero podés
|
|
78
|
+
* inyectar el tuyo (Redis, DB, etc.) implementando esta interfaz para que la
|
|
79
|
+
* verificación funcione entre procesos o sobreviva reinicios.
|
|
80
|
+
*/
|
|
81
|
+
interface CodeStore {
|
|
82
|
+
get(key: string): Promise<StoredCode | undefined> | StoredCode | undefined;
|
|
83
|
+
set(key: string, value: StoredCode): Promise<void> | void;
|
|
84
|
+
delete(key: string): Promise<void> | void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Store en memoria con expiración. Suficiente para un solo proceso / pruebas.
|
|
88
|
+
* No sirve si corrés varias instancias: usá un store compartido en ese caso.
|
|
89
|
+
*/
|
|
90
|
+
declare class MemoryCodeStore implements CodeStore {
|
|
91
|
+
private map;
|
|
92
|
+
get(key: string): StoredCode | undefined;
|
|
93
|
+
set(key: string, value: StoredCode): void;
|
|
94
|
+
delete(key: string): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface OtpSender {
|
|
98
|
+
sendText(input: SendTextInput): Promise<SendResult>;
|
|
99
|
+
}
|
|
100
|
+
interface OtpManagerOptions {
|
|
101
|
+
/** Store de códigos. Default: MemoryCodeStore (en memoria, un solo proceso). */
|
|
102
|
+
store?: CodeStore;
|
|
103
|
+
/** Cantidad de dígitos del código. Default: 6. */
|
|
104
|
+
codeLength?: number;
|
|
105
|
+
/** Vigencia del código en ms. Default: 300000 (5 min). */
|
|
106
|
+
ttlMs?: number;
|
|
107
|
+
/** Intentos de verificación permitidos antes de invalidar el código. Default: 3. */
|
|
108
|
+
maxAttempts?: number;
|
|
109
|
+
/** Sesión a usar para enviar el SMS/WhatsApp. Cae a la del cliente si se omite. */
|
|
110
|
+
sessionId?: string;
|
|
111
|
+
/** Genera el texto del mensaje. Recibe el código y los ms de vigencia. */
|
|
112
|
+
template?: (code: string, ttlMs: number) => string;
|
|
113
|
+
/** Generador de código propio (ej: alfanumérico). Default: numérico de `codeLength` dígitos. */
|
|
114
|
+
generateCode?: () => string;
|
|
115
|
+
}
|
|
116
|
+
type VerifyReason = 'not_found' | 'expired' | 'mismatch' | 'too_many_attempts';
|
|
117
|
+
interface VerifyResult {
|
|
118
|
+
ok: boolean;
|
|
119
|
+
reason?: VerifyReason;
|
|
120
|
+
}
|
|
121
|
+
interface OtpSendResult extends SendResult {
|
|
122
|
+
/** Epoch ms en que vence el código. */
|
|
123
|
+
expiresAt: number;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Flujo de validación de número por código: genera un código, lo envía por
|
|
127
|
+
* WhatsApp (vía la cola anti-ban del servidor) y lo verifica contra un store.
|
|
128
|
+
* El estado vive del lado del cliente, así que es independiente del servidor.
|
|
129
|
+
*/
|
|
130
|
+
declare class OtpManager {
|
|
131
|
+
private sender;
|
|
132
|
+
private store;
|
|
133
|
+
private codeLength;
|
|
134
|
+
private ttlMs;
|
|
135
|
+
private maxAttempts;
|
|
136
|
+
private sessionId;
|
|
137
|
+
private template;
|
|
138
|
+
private generate;
|
|
139
|
+
constructor(sender: OtpSender, opts?: OtpManagerOptions);
|
|
140
|
+
/** Genera y envía un código a `phone`. Reemplaza cualquier código previo del mismo número. */
|
|
141
|
+
send(phone: string): Promise<OtpSendResult>;
|
|
142
|
+
/** Verifica el código ingresado por el usuario. Consume el código si es correcto. */
|
|
143
|
+
verify(phone: string, code: string): Promise<VerifyResult>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
declare class MisterBotClient {
|
|
147
|
+
private baseUrl;
|
|
148
|
+
private token;
|
|
149
|
+
private sessionId;
|
|
150
|
+
private timeoutMs;
|
|
151
|
+
private fetchImpl;
|
|
152
|
+
constructor(opts: MisterBotClientOptions);
|
|
153
|
+
/** Envía un mensaje de texto. Resuelve cuando el servidor lo despachó por la cola anti-ban. */
|
|
154
|
+
sendText(input: SendTextInput): Promise<SendResult>;
|
|
155
|
+
/** Envía un archivo (imagen/video/audio/documento) a partir de datos binarios. */
|
|
156
|
+
sendMedia(input: SendMediaInput): Promise<SendResult>;
|
|
157
|
+
/** Conveniencia para Node: lee un archivo del disco y lo envía. */
|
|
158
|
+
sendMediaFile(input: SendMediaFileInput): Promise<SendResult>;
|
|
159
|
+
/** Estado actual de un mensaje (queued/sending/sent/delivered/read/failed). */
|
|
160
|
+
getStatus(id: string): Promise<MessageStatus>;
|
|
161
|
+
/** Sesiones que el token puede usar. */
|
|
162
|
+
listSessions(): Promise<Session[]>;
|
|
163
|
+
/**
|
|
164
|
+
* Pollea getStatus() hasta que el mensaje llegue a un estado terminal
|
|
165
|
+
* (sent/delivered/read/failed) o se agote el tiempo. Backoff: 1s,2s,3s,5s,8s...
|
|
166
|
+
*/
|
|
167
|
+
waitUntilSent(id: string, opts?: {
|
|
168
|
+
timeoutMs?: number;
|
|
169
|
+
minStatus?: 'sent' | 'delivered' | 'read';
|
|
170
|
+
}): Promise<MessageStatus>;
|
|
171
|
+
/** Crea un manejador de OTP (validación de número por código) atado a este cliente. */
|
|
172
|
+
otp(opts?: OtpManagerOptions): OtpManager;
|
|
173
|
+
private request;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Error de una llamada a la API de Mister Bot. `code` es el `statusMessage`
|
|
178
|
+
* que devuelve el servidor (ej: "session_not_connected", "invalid_token").
|
|
179
|
+
*/
|
|
180
|
+
declare class MisterBotError extends Error {
|
|
181
|
+
readonly status: number;
|
|
182
|
+
readonly code: string;
|
|
183
|
+
constructor(status: number, code: string, message?: string);
|
|
184
|
+
/** true si el envío fue rechazado por cuota (token o anti-ban). No reintentar en loop. */
|
|
185
|
+
get isQuotaError(): boolean;
|
|
186
|
+
/** true si conviene reintentar con backoff (5xx). Los 4xx no se reintentan. */
|
|
187
|
+
get isRetryable(): boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { type CodeStore, type MediaData, MemoryCodeStore, type MessageStatus, type MessageStatusValue, MisterBotClient, type MisterBotClientOptions, MisterBotError, OtpManager, type OtpManagerOptions, type OtpSendResult, type SendMediaFileInput, type SendMediaInput, type SendResult, type SendTextInput, type Session, type StoredCode, type VerifyReason, type VerifyResult };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
interface MisterBotClientOptions {
|
|
2
|
+
/** Base URL del servidor Mister Bot, ej: "https://wa.miempresa.com". Sin "/" final. */
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Token de API (empieza con "mbk_"). Lo crea un admin desde el panel. */
|
|
5
|
+
token: string;
|
|
6
|
+
/** Sesión por defecto. Opcional si el token ya tiene una sesión fija. */
|
|
7
|
+
sessionId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Timeout por request en ms. El servidor encola el envío y responde recién
|
|
10
|
+
* cuando el mensaje salió, así que un envío puede tardar (jitter anti-ban).
|
|
11
|
+
* Default: 120000 (2 min).
|
|
12
|
+
*/
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Implementación de fetch a usar. Default: el `fetch` global (Node >=18 / browser). */
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
interface SendTextInput {
|
|
18
|
+
/** Número internacional sin "+" ni espacios. Ej: "5491133334444". */
|
|
19
|
+
to: string;
|
|
20
|
+
/** Texto del mensaje (1..4096 chars). */
|
|
21
|
+
text: string;
|
|
22
|
+
/** Override de la sesión para este envío. */
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
}
|
|
25
|
+
type MediaData = Blob | ArrayBuffer | ArrayBufferView;
|
|
26
|
+
interface SendMediaInput {
|
|
27
|
+
to: string;
|
|
28
|
+
/** Contenido binario del archivo (Blob, Buffer, Uint8Array, ArrayBuffer). */
|
|
29
|
+
data: MediaData;
|
|
30
|
+
/** MIME del archivo, ej: "image/jpeg", "application/pdf". */
|
|
31
|
+
mimeType: string;
|
|
32
|
+
/** Nombre de archivo (recomendado para documentos). */
|
|
33
|
+
filename?: string;
|
|
34
|
+
/** Leyenda (solo imagen/video/documento). */
|
|
35
|
+
caption?: string;
|
|
36
|
+
sessionId?: string;
|
|
37
|
+
}
|
|
38
|
+
interface SendMediaFileInput {
|
|
39
|
+
to: string;
|
|
40
|
+
/** Ruta en disco del archivo a enviar (solo Node). */
|
|
41
|
+
path: string;
|
|
42
|
+
/** MIME explícito. Si se omite se infiere de la extensión. */
|
|
43
|
+
mimeType?: string;
|
|
44
|
+
caption?: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
}
|
|
47
|
+
interface SendResult {
|
|
48
|
+
/** UUID del mensaje. Usalo con getStatus() / waitUntilSent(). */
|
|
49
|
+
id: string;
|
|
50
|
+
}
|
|
51
|
+
type MessageStatusValue = 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'received';
|
|
52
|
+
interface MessageStatus {
|
|
53
|
+
id: string;
|
|
54
|
+
status: MessageStatusValue;
|
|
55
|
+
type: string;
|
|
56
|
+
to: string;
|
|
57
|
+
sentAt: string | null;
|
|
58
|
+
deliveredAt: string | null;
|
|
59
|
+
readAt: string | null;
|
|
60
|
+
failedAt: string | null;
|
|
61
|
+
error: string | null;
|
|
62
|
+
}
|
|
63
|
+
interface Session {
|
|
64
|
+
id: string;
|
|
65
|
+
name: string;
|
|
66
|
+
phoneNumber: string | null;
|
|
67
|
+
status: string;
|
|
68
|
+
isPaused: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface StoredCode {
|
|
72
|
+
code: string;
|
|
73
|
+
expiresAt: number;
|
|
74
|
+
attempts: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Almacén de códigos OTP. Por defecto el SDK usa uno en memoria, pero podés
|
|
78
|
+
* inyectar el tuyo (Redis, DB, etc.) implementando esta interfaz para que la
|
|
79
|
+
* verificación funcione entre procesos o sobreviva reinicios.
|
|
80
|
+
*/
|
|
81
|
+
interface CodeStore {
|
|
82
|
+
get(key: string): Promise<StoredCode | undefined> | StoredCode | undefined;
|
|
83
|
+
set(key: string, value: StoredCode): Promise<void> | void;
|
|
84
|
+
delete(key: string): Promise<void> | void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Store en memoria con expiración. Suficiente para un solo proceso / pruebas.
|
|
88
|
+
* No sirve si corrés varias instancias: usá un store compartido en ese caso.
|
|
89
|
+
*/
|
|
90
|
+
declare class MemoryCodeStore implements CodeStore {
|
|
91
|
+
private map;
|
|
92
|
+
get(key: string): StoredCode | undefined;
|
|
93
|
+
set(key: string, value: StoredCode): void;
|
|
94
|
+
delete(key: string): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface OtpSender {
|
|
98
|
+
sendText(input: SendTextInput): Promise<SendResult>;
|
|
99
|
+
}
|
|
100
|
+
interface OtpManagerOptions {
|
|
101
|
+
/** Store de códigos. Default: MemoryCodeStore (en memoria, un solo proceso). */
|
|
102
|
+
store?: CodeStore;
|
|
103
|
+
/** Cantidad de dígitos del código. Default: 6. */
|
|
104
|
+
codeLength?: number;
|
|
105
|
+
/** Vigencia del código en ms. Default: 300000 (5 min). */
|
|
106
|
+
ttlMs?: number;
|
|
107
|
+
/** Intentos de verificación permitidos antes de invalidar el código. Default: 3. */
|
|
108
|
+
maxAttempts?: number;
|
|
109
|
+
/** Sesión a usar para enviar el SMS/WhatsApp. Cae a la del cliente si se omite. */
|
|
110
|
+
sessionId?: string;
|
|
111
|
+
/** Genera el texto del mensaje. Recibe el código y los ms de vigencia. */
|
|
112
|
+
template?: (code: string, ttlMs: number) => string;
|
|
113
|
+
/** Generador de código propio (ej: alfanumérico). Default: numérico de `codeLength` dígitos. */
|
|
114
|
+
generateCode?: () => string;
|
|
115
|
+
}
|
|
116
|
+
type VerifyReason = 'not_found' | 'expired' | 'mismatch' | 'too_many_attempts';
|
|
117
|
+
interface VerifyResult {
|
|
118
|
+
ok: boolean;
|
|
119
|
+
reason?: VerifyReason;
|
|
120
|
+
}
|
|
121
|
+
interface OtpSendResult extends SendResult {
|
|
122
|
+
/** Epoch ms en que vence el código. */
|
|
123
|
+
expiresAt: number;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Flujo de validación de número por código: genera un código, lo envía por
|
|
127
|
+
* WhatsApp (vía la cola anti-ban del servidor) y lo verifica contra un store.
|
|
128
|
+
* El estado vive del lado del cliente, así que es independiente del servidor.
|
|
129
|
+
*/
|
|
130
|
+
declare class OtpManager {
|
|
131
|
+
private sender;
|
|
132
|
+
private store;
|
|
133
|
+
private codeLength;
|
|
134
|
+
private ttlMs;
|
|
135
|
+
private maxAttempts;
|
|
136
|
+
private sessionId;
|
|
137
|
+
private template;
|
|
138
|
+
private generate;
|
|
139
|
+
constructor(sender: OtpSender, opts?: OtpManagerOptions);
|
|
140
|
+
/** Genera y envía un código a `phone`. Reemplaza cualquier código previo del mismo número. */
|
|
141
|
+
send(phone: string): Promise<OtpSendResult>;
|
|
142
|
+
/** Verifica el código ingresado por el usuario. Consume el código si es correcto. */
|
|
143
|
+
verify(phone: string, code: string): Promise<VerifyResult>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
declare class MisterBotClient {
|
|
147
|
+
private baseUrl;
|
|
148
|
+
private token;
|
|
149
|
+
private sessionId;
|
|
150
|
+
private timeoutMs;
|
|
151
|
+
private fetchImpl;
|
|
152
|
+
constructor(opts: MisterBotClientOptions);
|
|
153
|
+
/** Envía un mensaje de texto. Resuelve cuando el servidor lo despachó por la cola anti-ban. */
|
|
154
|
+
sendText(input: SendTextInput): Promise<SendResult>;
|
|
155
|
+
/** Envía un archivo (imagen/video/audio/documento) a partir de datos binarios. */
|
|
156
|
+
sendMedia(input: SendMediaInput): Promise<SendResult>;
|
|
157
|
+
/** Conveniencia para Node: lee un archivo del disco y lo envía. */
|
|
158
|
+
sendMediaFile(input: SendMediaFileInput): Promise<SendResult>;
|
|
159
|
+
/** Estado actual de un mensaje (queued/sending/sent/delivered/read/failed). */
|
|
160
|
+
getStatus(id: string): Promise<MessageStatus>;
|
|
161
|
+
/** Sesiones que el token puede usar. */
|
|
162
|
+
listSessions(): Promise<Session[]>;
|
|
163
|
+
/**
|
|
164
|
+
* Pollea getStatus() hasta que el mensaje llegue a un estado terminal
|
|
165
|
+
* (sent/delivered/read/failed) o se agote el tiempo. Backoff: 1s,2s,3s,5s,8s...
|
|
166
|
+
*/
|
|
167
|
+
waitUntilSent(id: string, opts?: {
|
|
168
|
+
timeoutMs?: number;
|
|
169
|
+
minStatus?: 'sent' | 'delivered' | 'read';
|
|
170
|
+
}): Promise<MessageStatus>;
|
|
171
|
+
/** Crea un manejador de OTP (validación de número por código) atado a este cliente. */
|
|
172
|
+
otp(opts?: OtpManagerOptions): OtpManager;
|
|
173
|
+
private request;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Error de una llamada a la API de Mister Bot. `code` es el `statusMessage`
|
|
178
|
+
* que devuelve el servidor (ej: "session_not_connected", "invalid_token").
|
|
179
|
+
*/
|
|
180
|
+
declare class MisterBotError extends Error {
|
|
181
|
+
readonly status: number;
|
|
182
|
+
readonly code: string;
|
|
183
|
+
constructor(status: number, code: string, message?: string);
|
|
184
|
+
/** true si el envío fue rechazado por cuota (token o anti-ban). No reintentar en loop. */
|
|
185
|
+
get isQuotaError(): boolean;
|
|
186
|
+
/** true si conviene reintentar con backoff (5xx). Los 4xx no se reintentan. */
|
|
187
|
+
get isRetryable(): boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { type CodeStore, type MediaData, MemoryCodeStore, type MessageStatus, type MessageStatusValue, MisterBotClient, type MisterBotClientOptions, MisterBotError, OtpManager, type OtpManagerOptions, type OtpSendResult, type SendMediaFileInput, type SendMediaInput, type SendResult, type SendTextInput, type Session, type StoredCode, type VerifyReason, type VerifyResult };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var QUOTA_CODES = /* @__PURE__ */ new Set([
|
|
3
|
+
"daily_quota_exceeded",
|
|
4
|
+
"monthly_quota_exceeded",
|
|
5
|
+
"UNKNOWN_QUOTA_EXCEEDED",
|
|
6
|
+
"TOTAL_QUOTA_EXCEEDED"
|
|
7
|
+
]);
|
|
8
|
+
var MisterBotError = class extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
constructor(status, code, message) {
|
|
12
|
+
super(message ?? code);
|
|
13
|
+
this.name = "MisterBotError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
/** true si el envío fue rechazado por cuota (token o anti-ban). No reintentar en loop. */
|
|
18
|
+
get isQuotaError() {
|
|
19
|
+
return QUOTA_CODES.has(this.code);
|
|
20
|
+
}
|
|
21
|
+
/** true si conviene reintentar con backoff (5xx). Los 4xx no se reintentan. */
|
|
22
|
+
get isRetryable() {
|
|
23
|
+
return this.status >= 500;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/store.ts
|
|
28
|
+
var MemoryCodeStore = class {
|
|
29
|
+
map = /* @__PURE__ */ new Map();
|
|
30
|
+
get(key) {
|
|
31
|
+
const v = this.map.get(key);
|
|
32
|
+
if (!v) return void 0;
|
|
33
|
+
if (v.expiresAt <= Date.now()) {
|
|
34
|
+
this.map.delete(key);
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
set(key, value) {
|
|
40
|
+
this.map.set(key, value);
|
|
41
|
+
}
|
|
42
|
+
delete(key) {
|
|
43
|
+
this.map.delete(key);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/otp.ts
|
|
48
|
+
function defaultTemplate(code, ttlMs) {
|
|
49
|
+
const mins = Math.max(1, Math.round(ttlMs / 6e4));
|
|
50
|
+
return `Tu c\xF3digo de verificaci\xF3n es: ${code}
|
|
51
|
+
|
|
52
|
+
Vence en ${mins} minuto${mins === 1 ? "" : "s"}. No lo compartas con nadie.`;
|
|
53
|
+
}
|
|
54
|
+
function randomDigits(length) {
|
|
55
|
+
const g = globalThis;
|
|
56
|
+
let out = "";
|
|
57
|
+
if (g.crypto?.getRandomValues) {
|
|
58
|
+
const buf = new Uint32Array(length);
|
|
59
|
+
g.crypto.getRandomValues(buf);
|
|
60
|
+
for (let i = 0; i < length; i++) out += String((buf[i] ?? 0) % 10);
|
|
61
|
+
} else {
|
|
62
|
+
for (let i = 0; i < length; i++) out += String(Math.floor(Math.random() * 10));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function timingSafeEqual(a, b) {
|
|
67
|
+
if (a.length !== b.length) return false;
|
|
68
|
+
let diff = 0;
|
|
69
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
70
|
+
return diff === 0;
|
|
71
|
+
}
|
|
72
|
+
function normalizePhone(phone) {
|
|
73
|
+
return phone.replace(/[^0-9]/g, "");
|
|
74
|
+
}
|
|
75
|
+
var OtpManager = class {
|
|
76
|
+
sender;
|
|
77
|
+
store;
|
|
78
|
+
codeLength;
|
|
79
|
+
ttlMs;
|
|
80
|
+
maxAttempts;
|
|
81
|
+
sessionId;
|
|
82
|
+
template;
|
|
83
|
+
generate;
|
|
84
|
+
constructor(sender, opts = {}) {
|
|
85
|
+
this.sender = sender;
|
|
86
|
+
this.store = opts.store ?? new MemoryCodeStore();
|
|
87
|
+
this.codeLength = opts.codeLength ?? 6;
|
|
88
|
+
this.ttlMs = opts.ttlMs ?? 5 * 60 * 1e3;
|
|
89
|
+
this.maxAttempts = opts.maxAttempts ?? 3;
|
|
90
|
+
this.sessionId = opts.sessionId;
|
|
91
|
+
this.template = opts.template ?? defaultTemplate;
|
|
92
|
+
this.generate = opts.generateCode ?? (() => randomDigits(this.codeLength));
|
|
93
|
+
}
|
|
94
|
+
/** Genera y envía un código a `phone`. Reemplaza cualquier código previo del mismo número. */
|
|
95
|
+
async send(phone) {
|
|
96
|
+
const key = normalizePhone(phone);
|
|
97
|
+
const code = this.generate();
|
|
98
|
+
const expiresAt = Date.now() + this.ttlMs;
|
|
99
|
+
await this.store.set(key, { code, expiresAt, attempts: 0 });
|
|
100
|
+
const result = await this.sender.sendText({
|
|
101
|
+
to: key,
|
|
102
|
+
text: this.template(code, this.ttlMs),
|
|
103
|
+
sessionId: this.sessionId
|
|
104
|
+
});
|
|
105
|
+
return { id: result.id, expiresAt };
|
|
106
|
+
}
|
|
107
|
+
/** Verifica el código ingresado por el usuario. Consume el código si es correcto. */
|
|
108
|
+
async verify(phone, code) {
|
|
109
|
+
const key = normalizePhone(phone);
|
|
110
|
+
const entry = await this.store.get(key);
|
|
111
|
+
if (!entry) return { ok: false, reason: "not_found" };
|
|
112
|
+
if (entry.expiresAt <= Date.now()) {
|
|
113
|
+
await this.store.delete(key);
|
|
114
|
+
return { ok: false, reason: "expired" };
|
|
115
|
+
}
|
|
116
|
+
if (entry.attempts >= this.maxAttempts) {
|
|
117
|
+
await this.store.delete(key);
|
|
118
|
+
return { ok: false, reason: "too_many_attempts" };
|
|
119
|
+
}
|
|
120
|
+
if (timingSafeEqual(entry.code, code.trim())) {
|
|
121
|
+
await this.store.delete(key);
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
const attempts = entry.attempts + 1;
|
|
125
|
+
if (attempts >= this.maxAttempts) {
|
|
126
|
+
await this.store.delete(key);
|
|
127
|
+
return { ok: false, reason: "too_many_attempts" };
|
|
128
|
+
}
|
|
129
|
+
await this.store.set(key, { ...entry, attempts });
|
|
130
|
+
return { ok: false, reason: "mismatch" };
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// src/client.ts
|
|
135
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
136
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["sent", "delivered", "read", "failed"]);
|
|
137
|
+
var EXT_MIME = {
|
|
138
|
+
jpg: "image/jpeg",
|
|
139
|
+
jpeg: "image/jpeg",
|
|
140
|
+
png: "image/png",
|
|
141
|
+
webp: "image/webp",
|
|
142
|
+
gif: "image/gif",
|
|
143
|
+
mp4: "video/mp4",
|
|
144
|
+
"3gp": "video/3gpp",
|
|
145
|
+
webm: "video/webm",
|
|
146
|
+
ogg: "audio/ogg",
|
|
147
|
+
mp3: "audio/mpeg",
|
|
148
|
+
m4a: "audio/mp4",
|
|
149
|
+
aac: "audio/aac",
|
|
150
|
+
pdf: "application/pdf",
|
|
151
|
+
zip: "application/zip",
|
|
152
|
+
txt: "text/plain",
|
|
153
|
+
csv: "text/csv"
|
|
154
|
+
};
|
|
155
|
+
var MisterBotClient = class {
|
|
156
|
+
baseUrl;
|
|
157
|
+
token;
|
|
158
|
+
sessionId;
|
|
159
|
+
timeoutMs;
|
|
160
|
+
fetchImpl;
|
|
161
|
+
constructor(opts) {
|
|
162
|
+
if (!opts.baseUrl) throw new Error("baseUrl is required");
|
|
163
|
+
if (!opts.token) throw new Error("token is required");
|
|
164
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
165
|
+
this.token = opts.token;
|
|
166
|
+
this.sessionId = opts.sessionId;
|
|
167
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
168
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
169
|
+
if (!f) throw new Error("No fetch available \u2014 pass options.fetch (Node <18 needs a polyfill)");
|
|
170
|
+
this.fetchImpl = f;
|
|
171
|
+
}
|
|
172
|
+
/** Envía un mensaje de texto. Resuelve cuando el servidor lo despachó por la cola anti-ban. */
|
|
173
|
+
async sendText(input) {
|
|
174
|
+
const body = {
|
|
175
|
+
to: input.to,
|
|
176
|
+
text: input.text,
|
|
177
|
+
sessionId: input.sessionId ?? this.sessionId
|
|
178
|
+
};
|
|
179
|
+
const res = await this.request("POST", "/api/v1/send/text", {
|
|
180
|
+
headers: { "content-type": "application/json" },
|
|
181
|
+
body: JSON.stringify(body)
|
|
182
|
+
});
|
|
183
|
+
return { id: res.id };
|
|
184
|
+
}
|
|
185
|
+
/** Envía un archivo (imagen/video/audio/documento) a partir de datos binarios. */
|
|
186
|
+
async sendMedia(input) {
|
|
187
|
+
const blob = toBlob(input.data, input.mimeType);
|
|
188
|
+
const form = new FormData();
|
|
189
|
+
form.append("file", blob, input.filename ?? "file");
|
|
190
|
+
form.append("to", input.to);
|
|
191
|
+
if (input.caption) form.append("caption", input.caption);
|
|
192
|
+
const sessionId = input.sessionId ?? this.sessionId;
|
|
193
|
+
if (sessionId) form.append("sessionId", sessionId);
|
|
194
|
+
const res = await this.request("POST", "/api/v1/send/media", { body: form });
|
|
195
|
+
return { id: res.id };
|
|
196
|
+
}
|
|
197
|
+
/** Conveniencia para Node: lee un archivo del disco y lo envía. */
|
|
198
|
+
async sendMediaFile(input) {
|
|
199
|
+
const { readFile } = await import("fs/promises");
|
|
200
|
+
const data = await readFile(input.path);
|
|
201
|
+
const mimeType = input.mimeType ?? inferMime(input.path);
|
|
202
|
+
if (!mimeType) throw new Error(`No se pudo inferir el MIME de "${input.path}" \u2014 pas\xE1 mimeType.`);
|
|
203
|
+
const filename = input.path.split(/[\\/]/).pop() ?? "file";
|
|
204
|
+
return this.sendMedia({
|
|
205
|
+
to: input.to,
|
|
206
|
+
data: new Uint8Array(data),
|
|
207
|
+
mimeType,
|
|
208
|
+
filename,
|
|
209
|
+
caption: input.caption,
|
|
210
|
+
sessionId: input.sessionId
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** Estado actual de un mensaje (queued/sending/sent/delivered/read/failed). */
|
|
214
|
+
async getStatus(id) {
|
|
215
|
+
const res = await this.request("GET", `/api/v1/status/${encodeURIComponent(id)}`);
|
|
216
|
+
return res;
|
|
217
|
+
}
|
|
218
|
+
/** Sesiones que el token puede usar. */
|
|
219
|
+
async listSessions() {
|
|
220
|
+
const res = await this.request("GET", "/api/v1/sessions");
|
|
221
|
+
return res.sessions ?? [];
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Pollea getStatus() hasta que el mensaje llegue a un estado terminal
|
|
225
|
+
* (sent/delivered/read/failed) o se agote el tiempo. Backoff: 1s,2s,3s,5s,8s...
|
|
226
|
+
*/
|
|
227
|
+
async waitUntilSent(id, opts = {}) {
|
|
228
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
|
|
229
|
+
const target = opts.minStatus ?? "sent";
|
|
230
|
+
const rank = { queued: 0, sending: 1, sent: 2, delivered: 3, read: 4 };
|
|
231
|
+
const delays = [1e3, 2e3, 3e3, 5e3, 8e3];
|
|
232
|
+
let attempt = 0;
|
|
233
|
+
for (; ; ) {
|
|
234
|
+
const s = await this.getStatus(id);
|
|
235
|
+
if (s.status === "failed") return s;
|
|
236
|
+
if ((rank[s.status] ?? 0) >= (rank[target] ?? 2)) return s;
|
|
237
|
+
if (!TERMINAL_STATUSES.has(s.status) && Date.now() >= deadline) return s;
|
|
238
|
+
const wait = delays[Math.min(attempt, delays.length - 1)] ?? 8e3;
|
|
239
|
+
if (Date.now() + wait > deadline) return s;
|
|
240
|
+
await sleep(wait);
|
|
241
|
+
attempt++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** Crea un manejador de OTP (validación de número por código) atado a este cliente. */
|
|
245
|
+
otp(opts = {}) {
|
|
246
|
+
return new OtpManager(this, { sessionId: this.sessionId, ...opts });
|
|
247
|
+
}
|
|
248
|
+
async request(method, path, init = {}) {
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
251
|
+
let res;
|
|
252
|
+
try {
|
|
253
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
254
|
+
method,
|
|
255
|
+
headers: { authorization: `Bearer ${this.token}`, ...init.headers },
|
|
256
|
+
body: init.body,
|
|
257
|
+
signal: controller.signal
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (err?.name === "AbortError") {
|
|
261
|
+
throw new MisterBotError(408, "request_timeout", `La request super\xF3 ${this.timeoutMs}ms`);
|
|
262
|
+
}
|
|
263
|
+
throw new MisterBotError(0, "network_error", String(err?.message ?? err));
|
|
264
|
+
} finally {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
const text = await res.text();
|
|
268
|
+
let json = {};
|
|
269
|
+
if (text) {
|
|
270
|
+
try {
|
|
271
|
+
json = JSON.parse(text);
|
|
272
|
+
} catch {
|
|
273
|
+
json = {};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!res.ok) {
|
|
277
|
+
const code = json.statusMessage || json.message || `http_${res.status}`;
|
|
278
|
+
throw new MisterBotError(res.status, code, json.message || code);
|
|
279
|
+
}
|
|
280
|
+
return json;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
function toBlob(data, mimeType) {
|
|
284
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) return data;
|
|
285
|
+
if (data instanceof ArrayBuffer) return new Blob([data], { type: mimeType });
|
|
286
|
+
if (ArrayBuffer.isView(data)) {
|
|
287
|
+
return new Blob([data], { type: mimeType });
|
|
288
|
+
}
|
|
289
|
+
throw new Error("data debe ser Blob, ArrayBuffer o ArrayBufferView (Buffer/Uint8Array)");
|
|
290
|
+
}
|
|
291
|
+
function inferMime(path) {
|
|
292
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
293
|
+
return EXT_MIME[ext];
|
|
294
|
+
}
|
|
295
|
+
function sleep(ms) {
|
|
296
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
297
|
+
}
|
|
298
|
+
export {
|
|
299
|
+
MemoryCodeStore,
|
|
300
|
+
MisterBotClient,
|
|
301
|
+
MisterBotError,
|
|
302
|
+
OtpManager
|
|
303
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "misterbot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SDK oficial de Mister Bot: envío de mensajes de WhatsApp con cola anti-ban y verificación de número por código (OTP).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"whatsapp",
|
|
7
|
+
"baileys",
|
|
8
|
+
"otp",
|
|
9
|
+
"verification",
|
|
10
|
+
"mister-bot",
|
|
11
|
+
"sdk"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.cjs",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js",
|
|
22
|
+
"require": "./dist/index.cjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean --target node18",
|
|
32
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"tsup": "^8.0.0",
|
|
41
|
+
"typescript": "^5.4.0"
|
|
42
|
+
}
|
|
43
|
+
}
|