@bot-wa/baileys 0.0.0-stage → 0.2.0-alpha.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.md +21 -0
- package/README.md +283 -2
- package/lib/BaileysProvider.d.ts +246 -0
- package/lib/BaileysProvider.js +858 -0
- package/lib/MediaProcessor.d.ts +60 -0
- package/lib/MediaProcessor.js +137 -0
- package/lib/RateLimiter.d.ts +115 -0
- package/lib/RateLimiter.js +286 -0
- package/lib/boundedMap.d.ts +13 -0
- package/lib/boundedMap.js +22 -0
- package/lib/browser.d.ts +15 -0
- package/lib/browser.js +41 -0
- package/lib/identity.d.ts +52 -0
- package/lib/identity.js +107 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +28 -0
- package/lib/jid.d.ts +70 -0
- package/lib/jid.js +119 -0
- package/lib/logger.d.ts +34 -0
- package/lib/logger.js +44 -0
- package/lib/mediaRef.d.ts +50 -0
- package/lib/mediaRef.js +136 -0
- package/lib/polls.d.ts +147 -0
- package/lib/polls.js +243 -0
- package/lib/reconnect.d.ts +41 -0
- package/lib/reconnect.js +51 -0
- package/lib/sessionPath.d.ts +14 -0
- package/lib/sessionPath.js +52 -0
- package/lib/toCallEvent.d.ts +19 -0
- package/lib/toCallEvent.js +46 -0
- package/lib/toInbound.d.ts +19 -0
- package/lib/toInbound.js +177 -0
- package/lib/toOutbound.d.ts +10 -0
- package/lib/toOutbound.js +62 -0
- package/package.json +70 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaProcessor — convert MediaSource inputs into Baileys' WAMediaUpload.
|
|
3
|
+
*
|
|
4
|
+
* Includes sticker conversion (sharp) and audio PTT transcode (fluent-ffmpeg).
|
|
5
|
+
*/
|
|
6
|
+
import { type MediaSource, type OutboundContent } from '@bot-wa/core';
|
|
7
|
+
import { type HostResolver } from '@bot-wa/http';
|
|
8
|
+
export type WAMediaUpload = Buffer | {
|
|
9
|
+
url: string;
|
|
10
|
+
} | {
|
|
11
|
+
stream: NodeJS.ReadableStream;
|
|
12
|
+
};
|
|
13
|
+
/** Normalize any MediaSource into a Baileys WAMediaUpload. */
|
|
14
|
+
export declare function toMediaUpload(source: MediaSource): WAMediaUpload;
|
|
15
|
+
/**
|
|
16
|
+
* How the provider fetches outbound `{ url }` / `https://…` media.
|
|
17
|
+
*
|
|
18
|
+
* Baileys downloads URL media with plain `fetch`, which follows redirects
|
|
19
|
+
* without checking where they land: a public URL that redirects to
|
|
20
|
+
* `http://169.254.169.254/…` would make the bot fetch (and send) internal data.
|
|
21
|
+
* By default the provider downloads remote media itself through
|
|
22
|
+
* `@bot-wa/http` (every redirect hop vetted, connection pinned to the vetted
|
|
23
|
+
* address, size and time capped) and hands Baileys a Buffer.
|
|
24
|
+
*/
|
|
25
|
+
export interface RemoteMediaConfig {
|
|
26
|
+
/**
|
|
27
|
+
* Accept media hosted on (or redirecting to) loopback, private or link-local
|
|
28
|
+
* addresses. `true` restores the legacy behavior: the URL is passed to
|
|
29
|
+
* Baileys untouched and streamed by it. Default `false`.
|
|
30
|
+
*/
|
|
31
|
+
allowPrivateHosts?: boolean;
|
|
32
|
+
/** Accept plain `http:` URLs (each hop is still vetted). Default `true`. */
|
|
33
|
+
allowHttp?: boolean;
|
|
34
|
+
/** Largest media accepted, in bytes. Default 64 MiB. */
|
|
35
|
+
maxBytes?: number;
|
|
36
|
+
/** Time allowed for the whole download. Default 60 s. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
/** Redirect hops allowed. Default 3. */
|
|
39
|
+
maxRedirects?: number;
|
|
40
|
+
/** DNS resolver used to vet hosts (tests, egress gateways). */
|
|
41
|
+
resolver?: HostResolver;
|
|
42
|
+
/** Custom fetch (tests, egress proxies). */
|
|
43
|
+
fetch?: typeof globalThis.fetch;
|
|
44
|
+
}
|
|
45
|
+
export declare const REMOTE_MEDIA_MAX_BYTES: number;
|
|
46
|
+
/**
|
|
47
|
+
* Download `{ url }` media through `@bot-wa/http` and return a Buffer, unless
|
|
48
|
+
* {@link RemoteMediaConfig.allowPrivateHosts} opts back into the pass-through.
|
|
49
|
+
* Rejects with {@link PayloadTooLargeError} over the size cap and
|
|
50
|
+
* {@link UnsupportedContentError} for an unsafe or failing URL.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveRemoteMedia(upload: WAMediaUpload, kind: OutboundContent['kind'], config?: RemoteMediaConfig): Promise<WAMediaUpload>;
|
|
53
|
+
/** Read a MediaSource fully into a Buffer (used by sticker/audio transcode). */
|
|
54
|
+
export declare function toBuffer(source: MediaSource): Promise<Buffer>;
|
|
55
|
+
export interface StickerOptions {
|
|
56
|
+
pack?: string;
|
|
57
|
+
author?: string;
|
|
58
|
+
}
|
|
59
|
+
export declare function makeSticker(source: MediaSource, opts?: StickerOptions): Promise<Buffer>;
|
|
60
|
+
export declare function transcodeToOpus(source: MediaSource): Promise<Buffer>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaProcessor — convert MediaSource inputs into Baileys' WAMediaUpload.
|
|
3
|
+
*
|
|
4
|
+
* Includes sticker conversion (sharp) and audio PTT transcode (fluent-ffmpeg).
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, existsSync } from 'fs';
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import ffmpeg from 'fluent-ffmpeg';
|
|
9
|
+
import ffmpegInstaller from '@ffmpeg-installer/ffmpeg';
|
|
10
|
+
import { tmpdir } from 'os';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { readFile } from 'fs/promises';
|
|
13
|
+
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
|
14
|
+
import { PayloadTooLargeError, UnsupportedContentError } from '@bot-wa/core';
|
|
15
|
+
import { fetchBuffer, SecureHttpError } from '@bot-wa/http';
|
|
16
|
+
/** Normalize any MediaSource into a Baileys WAMediaUpload. */
|
|
17
|
+
export function toMediaUpload(source) {
|
|
18
|
+
if (typeof source === 'string') {
|
|
19
|
+
// URL → leave as URL (Baileys streams)
|
|
20
|
+
if (/^https?:\/\//i.test(source) || !existsSync(source)) {
|
|
21
|
+
return { url: source };
|
|
22
|
+
}
|
|
23
|
+
// Local path → Buffer (could also be { url: 'file://...' })
|
|
24
|
+
return readFileSync(source);
|
|
25
|
+
}
|
|
26
|
+
if (Buffer.isBuffer(source))
|
|
27
|
+
return source;
|
|
28
|
+
if (source.url)
|
|
29
|
+
return { url: source.url };
|
|
30
|
+
if (source.buffer)
|
|
31
|
+
return source.buffer;
|
|
32
|
+
if (source.path)
|
|
33
|
+
return readFileSync(source.path);
|
|
34
|
+
if (source.stream)
|
|
35
|
+
return { stream: source.stream };
|
|
36
|
+
throw new Error(`Invalid MediaSource: ${typeof source}`);
|
|
37
|
+
}
|
|
38
|
+
export const REMOTE_MEDIA_MAX_BYTES = 64 * 1024 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* Download `{ url }` media through `@bot-wa/http` and return a Buffer, unless
|
|
41
|
+
* {@link RemoteMediaConfig.allowPrivateHosts} opts back into the pass-through.
|
|
42
|
+
* Rejects with {@link PayloadTooLargeError} over the size cap and
|
|
43
|
+
* {@link UnsupportedContentError} for an unsafe or failing URL.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveRemoteMedia(upload, kind, config = {}) {
|
|
46
|
+
if (Buffer.isBuffer(upload) || !('url' in upload) || !/^https?:\/\//i.test(upload.url))
|
|
47
|
+
return upload;
|
|
48
|
+
if (config.allowPrivateHosts === true)
|
|
49
|
+
return upload;
|
|
50
|
+
try {
|
|
51
|
+
return await fetchBuffer(upload.url, {
|
|
52
|
+
allowHttp: config.allowHttp !== false,
|
|
53
|
+
maxBytes: config.maxBytes ?? REMOTE_MEDIA_MAX_BYTES,
|
|
54
|
+
timeoutMs: config.timeoutMs ?? 60_000,
|
|
55
|
+
...(config.maxRedirects !== undefined ? { maxRedirects: config.maxRedirects } : {}),
|
|
56
|
+
...(config.resolver ? { resolver: config.resolver } : {}),
|
|
57
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof SecureHttpError && error.code === 'RESPONSE_TOO_LARGE') {
|
|
62
|
+
throw new PayloadTooLargeError(`${kind} media exceeds ${config.maxBytes ?? REMOTE_MEDIA_MAX_BYTES} bytes`);
|
|
63
|
+
}
|
|
64
|
+
const reason = error instanceof SecureHttpError ? error.message : 'download failed';
|
|
65
|
+
const wrapped = new UnsupportedContentError(kind, `${kind} media URL rejected: ${reason}`);
|
|
66
|
+
wrapped.cause = error;
|
|
67
|
+
throw wrapped;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Read a MediaSource fully into a Buffer (used by sticker/audio transcode). */
|
|
71
|
+
export async function toBuffer(source) {
|
|
72
|
+
if (typeof source === 'string') {
|
|
73
|
+
if (/^https?:\/\//i.test(source)) {
|
|
74
|
+
throw new Error('Remote URL not supported here — download it first');
|
|
75
|
+
}
|
|
76
|
+
return readFile(source);
|
|
77
|
+
}
|
|
78
|
+
if (Buffer.isBuffer(source))
|
|
79
|
+
return source;
|
|
80
|
+
if (source.buffer)
|
|
81
|
+
return source.buffer;
|
|
82
|
+
if (source.path)
|
|
83
|
+
return readFile(source.path);
|
|
84
|
+
throw new Error('Cannot convert this MediaSource to buffer');
|
|
85
|
+
}
|
|
86
|
+
export async function makeSticker(source, opts = {}) {
|
|
87
|
+
const buffer = await toBuffer(source);
|
|
88
|
+
// PNG/WebP 512×512 transparent padding ( whatsapp sticker spec )
|
|
89
|
+
const padded = await sharp(buffer, { failOn: 'none' })
|
|
90
|
+
.resize(512, 512, {
|
|
91
|
+
fit: 'contain',
|
|
92
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
93
|
+
})
|
|
94
|
+
.extend({
|
|
95
|
+
top: 0, bottom: 0, left: 0, right: 0,
|
|
96
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
97
|
+
})
|
|
98
|
+
.webp()
|
|
99
|
+
.toBuffer();
|
|
100
|
+
if (!opts.pack && !opts.author)
|
|
101
|
+
return padded;
|
|
102
|
+
// EXIF packing with author/pack metadata omitted for now; returns plain WebP
|
|
103
|
+
return padded;
|
|
104
|
+
}
|
|
105
|
+
/* =============================================================================
|
|
106
|
+
* AUDIO (PTT transcode)
|
|
107
|
+
* ========================================================================== */
|
|
108
|
+
import { writeFile, unlink } from 'fs/promises';
|
|
109
|
+
import { randomUUID } from 'crypto';
|
|
110
|
+
export async function transcodeToOpus(source) {
|
|
111
|
+
const input = await toBuffer(source);
|
|
112
|
+
const inPath = join(tmpdir(), `baileys-in-${randomUUID()}.bin`);
|
|
113
|
+
const outPath = join(tmpdir(), `baileys-out-${randomUUID()}.ogg`);
|
|
114
|
+
await writeFile(inPath, input);
|
|
115
|
+
try {
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
ffmpeg(inPath)
|
|
118
|
+
.outputOptions([
|
|
119
|
+
'-c:a libopus',
|
|
120
|
+
'-ac 1',
|
|
121
|
+
'-avoid_negative_ts make_zero',
|
|
122
|
+
'-ar 48000',
|
|
123
|
+
])
|
|
124
|
+
.output(outPath)
|
|
125
|
+
.on('end', () => resolve())
|
|
126
|
+
.on('error', (err) => reject(err))
|
|
127
|
+
.run();
|
|
128
|
+
});
|
|
129
|
+
return await readFile(outPath);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
// Always clean up both temp files — even when ffmpeg rejects — so a stream
|
|
133
|
+
// of failed transcodes can't leak the OS temp dir.
|
|
134
|
+
await unlink(inPath).catch(() => { });
|
|
135
|
+
await unlink(outPath).catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RateLimiter — Token bucket rate limiter for outbound messages.
|
|
3
|
+
*
|
|
4
|
+
* WhatsApp bans accounts that send too many messages in a short period. This
|
|
5
|
+
* rate limiter sits in the `before('send')` hook pipeline and enforces:
|
|
6
|
+
* - Per-chat limits (e.g. max 5 messages per 10 seconds to the same chat)
|
|
7
|
+
* - Global limits (e.g. max 50 messages per 60 seconds across all chats)
|
|
8
|
+
* - Optional human-like jitter between sends
|
|
9
|
+
*
|
|
10
|
+
* When a limit is exceeded, the send is queued (not dropped) and released
|
|
11
|
+
* once the bucket refills. A blocked chat never stalls other chats: the queue
|
|
12
|
+
* is scanned for the first send whose buckets currently allow it. Sends that
|
|
13
|
+
* wait longer than `maxWait` are rejected so a caller can never hang forever.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const bot = new Bot({
|
|
18
|
+
* session: 'my-bot',
|
|
19
|
+
* rateLimit: {
|
|
20
|
+
* perChat: { max: 5, window: '10s' },
|
|
21
|
+
* global: { max: 50, window: '60s' },
|
|
22
|
+
* jitter: { min: 500, max: 1500 },
|
|
23
|
+
* },
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import { RateLimitedError } from '@bot-wa/core';
|
|
28
|
+
export interface RateLimitBucketConfig {
|
|
29
|
+
/** Maximum tokens (messages) allowed in the window. */
|
|
30
|
+
max: number;
|
|
31
|
+
/** Time window for the bucket (e.g. '10s', '1m'). */
|
|
32
|
+
window: string | number;
|
|
33
|
+
}
|
|
34
|
+
export interface RateLimitJitterConfig {
|
|
35
|
+
/** Minimum delay in ms between sends. */
|
|
36
|
+
min: number;
|
|
37
|
+
/** Maximum delay in ms between sends. */
|
|
38
|
+
max: number;
|
|
39
|
+
}
|
|
40
|
+
export interface RateLimitConfig {
|
|
41
|
+
/** Per-chat rate limit. Default: { max: 5, window: '10s' } */
|
|
42
|
+
perChat?: RateLimitBucketConfig;
|
|
43
|
+
/** Global rate limit across all chats. Default: { max: 50, window: '60s' } */
|
|
44
|
+
global?: RateLimitBucketConfig;
|
|
45
|
+
/** Human-like jitter between sends. Default: disabled. */
|
|
46
|
+
jitter?: RateLimitJitterConfig;
|
|
47
|
+
/** Set to false to disable rate limiting entirely. Default: true. */
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Max time a send may wait in the queue before it is rejected with
|
|
51
|
+
* {@link RateLimiterTimeout}. Prevents a mis-configured limit from hanging a
|
|
52
|
+
* caller forever. Default: '30s'. Set to `0` to wait indefinitely.
|
|
53
|
+
*/
|
|
54
|
+
maxWait?: string | number;
|
|
55
|
+
/**
|
|
56
|
+
* Max distinct per-chat buckets kept in memory. Idle (fully-refilled) buckets
|
|
57
|
+
* are evicted first, so eviction never lifts an active limit. Default: 5000.
|
|
58
|
+
*/
|
|
59
|
+
maxChats?: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Thrown when a queued send waits longer than the configured `maxWait`.
|
|
63
|
+
*
|
|
64
|
+
* A {@link RateLimitedError} (so a {@link ProviderError}), as the IProvider send
|
|
65
|
+
* contract requires: callers can `catch (e instanceof RateLimitedError)` the same
|
|
66
|
+
* way for this local limiter and for a server-side 429. `retryAfterMs` is the
|
|
67
|
+
* time until the blocking bucket refills one token.
|
|
68
|
+
*/
|
|
69
|
+
export declare class RateLimiterTimeout extends RateLimitedError {
|
|
70
|
+
readonly chatId: string;
|
|
71
|
+
readonly waitedMs: number;
|
|
72
|
+
constructor(chatId: string, waitedMs: number, retryAfterMs?: number);
|
|
73
|
+
}
|
|
74
|
+
export declare class RateLimiter {
|
|
75
|
+
private perChatBuckets;
|
|
76
|
+
private globalBucket;
|
|
77
|
+
private queue;
|
|
78
|
+
private processing;
|
|
79
|
+
private lastSendAt;
|
|
80
|
+
/** Bumped by reset() so an in-flight drain from a previous cycle bails out. */
|
|
81
|
+
private generation;
|
|
82
|
+
private readonly perChatMax;
|
|
83
|
+
private readonly perChatWindowMs;
|
|
84
|
+
private readonly maxChats;
|
|
85
|
+
private readonly maxWaitMs;
|
|
86
|
+
private readonly jitterMin;
|
|
87
|
+
private readonly jitterMax;
|
|
88
|
+
private readonly enabled;
|
|
89
|
+
constructor(config?: RateLimitConfig);
|
|
90
|
+
/**
|
|
91
|
+
* Acquire permission to send a message to `chatId`. Resolves when the send is
|
|
92
|
+
* allowed (may wait if rate-limited). Rejects with {@link RateLimiterTimeout}
|
|
93
|
+
* if it waits past `maxWait`, or a `ProviderError('RateLimiter reset')` if the
|
|
94
|
+
* limiter is reset (e.g. the provider stops) while the send is queued.
|
|
95
|
+
*/
|
|
96
|
+
acquire(chatId: string): Promise<void>;
|
|
97
|
+
/** Number of messages currently waiting in the queue. */
|
|
98
|
+
get pending(): number;
|
|
99
|
+
/** Number of per-chat buckets currently tracked (bounded by `maxChats`). */
|
|
100
|
+
get trackedChats(): number;
|
|
101
|
+
private processQueue;
|
|
102
|
+
private drain;
|
|
103
|
+
private tryConsume;
|
|
104
|
+
private refillBucket;
|
|
105
|
+
/** Time (ms) until both buckets gating `chatId` hold a token again (>= 1). */
|
|
106
|
+
private retryAfterFor;
|
|
107
|
+
/** Time (ms) until the soonest queued send could proceed — used instead of busy-polling. */
|
|
108
|
+
private nextRefillDelay;
|
|
109
|
+
/** Evict idle (fully-refilled) buckets when over `maxChats`; never drops an active limit. */
|
|
110
|
+
private pruneBuckets;
|
|
111
|
+
private computeJitter;
|
|
112
|
+
private sleep;
|
|
113
|
+
/** Clear all buckets and the queue (e.g. on bot stop). */
|
|
114
|
+
reset(): void;
|
|
115
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RateLimiter — Token bucket rate limiter for outbound messages.
|
|
3
|
+
*
|
|
4
|
+
* WhatsApp bans accounts that send too many messages in a short period. This
|
|
5
|
+
* rate limiter sits in the `before('send')` hook pipeline and enforces:
|
|
6
|
+
* - Per-chat limits (e.g. max 5 messages per 10 seconds to the same chat)
|
|
7
|
+
* - Global limits (e.g. max 50 messages per 60 seconds across all chats)
|
|
8
|
+
* - Optional human-like jitter between sends
|
|
9
|
+
*
|
|
10
|
+
* When a limit is exceeded, the send is queued (not dropped) and released
|
|
11
|
+
* once the bucket refills. A blocked chat never stalls other chats: the queue
|
|
12
|
+
* is scanned for the first send whose buckets currently allow it. Sends that
|
|
13
|
+
* wait longer than `maxWait` are rejected so a caller can never hang forever.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const bot = new Bot({
|
|
18
|
+
* session: 'my-bot',
|
|
19
|
+
* rateLimit: {
|
|
20
|
+
* perChat: { max: 5, window: '10s' },
|
|
21
|
+
* global: { max: 50, window: '60s' },
|
|
22
|
+
* jitter: { min: 500, max: 1500 },
|
|
23
|
+
* },
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import { parseDuration, ProviderError, RateLimitedError } from '@bot-wa/core';
|
|
28
|
+
const DEFAULT_PER_CHAT = { max: 5, window: '10s' };
|
|
29
|
+
const DEFAULT_GLOBAL = { max: 50, window: '60s' };
|
|
30
|
+
const DEFAULT_MAX_WAIT = '30s';
|
|
31
|
+
const DEFAULT_MAX_CHATS = 5000;
|
|
32
|
+
/** Retry hint used when the caller builds a timeout without a bucket to measure. */
|
|
33
|
+
const DEFAULT_RETRY_AFTER_MS = 1000;
|
|
34
|
+
/**
|
|
35
|
+
* Thrown when a queued send waits longer than the configured `maxWait`.
|
|
36
|
+
*
|
|
37
|
+
* A {@link RateLimitedError} (so a {@link ProviderError}), as the IProvider send
|
|
38
|
+
* contract requires: callers can `catch (e instanceof RateLimitedError)` the same
|
|
39
|
+
* way for this local limiter and for a server-side 429. `retryAfterMs` is the
|
|
40
|
+
* time until the blocking bucket refills one token.
|
|
41
|
+
*/
|
|
42
|
+
export class RateLimiterTimeout extends RateLimitedError {
|
|
43
|
+
chatId;
|
|
44
|
+
waitedMs;
|
|
45
|
+
constructor(chatId, waitedMs, retryAfterMs = DEFAULT_RETRY_AFTER_MS) {
|
|
46
|
+
super(retryAfterMs, `RateLimiter: send to ${chatId} exceeded maxWait after ${waitedMs}ms`);
|
|
47
|
+
this.chatId = chatId;
|
|
48
|
+
this.waitedMs = waitedMs;
|
|
49
|
+
this.name = 'RateLimiterTimeout';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class RateLimiter {
|
|
53
|
+
perChatBuckets = new Map();
|
|
54
|
+
globalBucket;
|
|
55
|
+
queue = [];
|
|
56
|
+
processing = false;
|
|
57
|
+
lastSendAt = 0;
|
|
58
|
+
/** Bumped by reset() so an in-flight drain from a previous cycle bails out. */
|
|
59
|
+
generation = 0;
|
|
60
|
+
perChatMax;
|
|
61
|
+
perChatWindowMs;
|
|
62
|
+
maxChats;
|
|
63
|
+
maxWaitMs;
|
|
64
|
+
jitterMin;
|
|
65
|
+
jitterMax;
|
|
66
|
+
enabled;
|
|
67
|
+
constructor(config = {}) {
|
|
68
|
+
this.enabled = config.enabled !== false;
|
|
69
|
+
const perChat = config.perChat ?? DEFAULT_PER_CHAT;
|
|
70
|
+
assertPositive('perChat.max', perChat.max);
|
|
71
|
+
this.perChatMax = perChat.max;
|
|
72
|
+
this.perChatWindowMs = parseDuration(perChat.window);
|
|
73
|
+
assertPositive('perChat.window', this.perChatWindowMs);
|
|
74
|
+
const global = config.global ?? DEFAULT_GLOBAL;
|
|
75
|
+
assertPositive('global.max', global.max);
|
|
76
|
+
const globalWindowMs = parseDuration(global.window);
|
|
77
|
+
assertPositive('global.window', globalWindowMs);
|
|
78
|
+
this.globalBucket = {
|
|
79
|
+
tokens: global.max,
|
|
80
|
+
lastRefill: Date.now(),
|
|
81
|
+
max: global.max,
|
|
82
|
+
windowMs: globalWindowMs,
|
|
83
|
+
};
|
|
84
|
+
this.jitterMin = config.jitter?.min ?? 0;
|
|
85
|
+
this.jitterMax = config.jitter?.max ?? 0;
|
|
86
|
+
this.maxWaitMs = config.maxWait !== undefined ? parseDuration(config.maxWait) : parseDuration(DEFAULT_MAX_WAIT);
|
|
87
|
+
this.maxChats = config.maxChats ?? DEFAULT_MAX_CHATS;
|
|
88
|
+
assertNonNegative('jitter.min', this.jitterMin);
|
|
89
|
+
assertNonNegative('jitter.max', this.jitterMax);
|
|
90
|
+
if (this.jitterMax < this.jitterMin) {
|
|
91
|
+
throw new RangeError('RateLimiter jitter.max must be greater than or equal to jitter.min');
|
|
92
|
+
}
|
|
93
|
+
assertNonNegative('maxWait', this.maxWaitMs);
|
|
94
|
+
assertPositiveInteger('maxChats', this.maxChats);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Acquire permission to send a message to `chatId`. Resolves when the send is
|
|
98
|
+
* allowed (may wait if rate-limited). Rejects with {@link RateLimiterTimeout}
|
|
99
|
+
* if it waits past `maxWait`, or a `ProviderError('RateLimiter reset')` if the
|
|
100
|
+
* limiter is reset (e.g. the provider stops) while the send is queued.
|
|
101
|
+
*/
|
|
102
|
+
async acquire(chatId) {
|
|
103
|
+
if (!this.enabled)
|
|
104
|
+
return;
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
this.queue.push({ resolve, reject, chatId, enqueuedAt: Date.now() });
|
|
107
|
+
this.processQueue();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Number of messages currently waiting in the queue. */
|
|
111
|
+
get pending() {
|
|
112
|
+
return this.queue.length;
|
|
113
|
+
}
|
|
114
|
+
/** Number of per-chat buckets currently tracked (bounded by `maxChats`). */
|
|
115
|
+
get trackedChats() {
|
|
116
|
+
return this.perChatBuckets.size;
|
|
117
|
+
}
|
|
118
|
+
processQueue() {
|
|
119
|
+
if (this.processing)
|
|
120
|
+
return;
|
|
121
|
+
this.processing = true;
|
|
122
|
+
const gen = this.generation;
|
|
123
|
+
void this.drain(gen);
|
|
124
|
+
}
|
|
125
|
+
async drain(gen) {
|
|
126
|
+
while (this.queue.length > 0) {
|
|
127
|
+
if (this.generation !== gen)
|
|
128
|
+
return; // reset() happened — abandon this drain
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
// Reject sends that have waited too long (walk backwards: splicing is safe).
|
|
131
|
+
if (this.maxWaitMs > 0) {
|
|
132
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
133
|
+
const it = this.queue[i];
|
|
134
|
+
const waited = now - it.enqueuedAt;
|
|
135
|
+
if (waited >= this.maxWaitMs) {
|
|
136
|
+
this.queue.splice(i, 1);
|
|
137
|
+
it.reject(new RateLimiterTimeout(it.chatId, waited, this.retryAfterFor(it.chatId, now)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (this.queue.length === 0)
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
// Send the FIRST queued item whose buckets allow it now — scanning past a
|
|
144
|
+
// blocked head keeps one rate-limited chat from stalling every other chat.
|
|
145
|
+
// FIFO within a chat is preserved: an older blocked item shares its bucket
|
|
146
|
+
// with newer items of the same chat, so they stay blocked together.
|
|
147
|
+
let sent = false;
|
|
148
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
149
|
+
if (this.tryConsume(this.queue[i].chatId, now)) {
|
|
150
|
+
const item = this.queue.splice(i, 1)[0];
|
|
151
|
+
const jitter = this.computeJitter();
|
|
152
|
+
if (jitter > 0)
|
|
153
|
+
await this.sleep(jitter);
|
|
154
|
+
this.lastSendAt = Date.now();
|
|
155
|
+
item.resolve();
|
|
156
|
+
sent = true;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!sent)
|
|
161
|
+
await this.sleep(this.nextRefillDelay(now));
|
|
162
|
+
}
|
|
163
|
+
if (this.generation === gen)
|
|
164
|
+
this.processing = false;
|
|
165
|
+
}
|
|
166
|
+
tryConsume(chatId, now = Date.now()) {
|
|
167
|
+
// Refill global bucket
|
|
168
|
+
this.refillBucket(this.globalBucket, now);
|
|
169
|
+
if (this.globalBucket.tokens < 1)
|
|
170
|
+
return false;
|
|
171
|
+
// Refill (creating if needed) per-chat bucket
|
|
172
|
+
let chatBucket = this.perChatBuckets.get(chatId);
|
|
173
|
+
if (!chatBucket) {
|
|
174
|
+
chatBucket = {
|
|
175
|
+
tokens: this.perChatMax,
|
|
176
|
+
lastRefill: now,
|
|
177
|
+
max: this.perChatMax,
|
|
178
|
+
windowMs: this.perChatWindowMs,
|
|
179
|
+
};
|
|
180
|
+
this.perChatBuckets.set(chatId, chatBucket);
|
|
181
|
+
this.pruneBuckets(now);
|
|
182
|
+
}
|
|
183
|
+
this.refillBucket(chatBucket, now);
|
|
184
|
+
if (chatBucket.tokens < 1)
|
|
185
|
+
return false;
|
|
186
|
+
// Consume from both
|
|
187
|
+
this.globalBucket.tokens -= 1;
|
|
188
|
+
chatBucket.tokens -= 1;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
refillBucket(bucket, now) {
|
|
192
|
+
const elapsed = now - bucket.lastRefill;
|
|
193
|
+
if (elapsed <= 0)
|
|
194
|
+
return;
|
|
195
|
+
const refillRate = bucket.max / bucket.windowMs;
|
|
196
|
+
const tokensToAdd = elapsed * refillRate;
|
|
197
|
+
bucket.tokens = Math.min(bucket.max, bucket.tokens + tokensToAdd);
|
|
198
|
+
bucket.lastRefill = now;
|
|
199
|
+
}
|
|
200
|
+
/** Time (ms) until both buckets gating `chatId` hold a token again (>= 1). */
|
|
201
|
+
retryAfterFor(chatId, now) {
|
|
202
|
+
this.refillBucket(this.globalBucket, now);
|
|
203
|
+
const chatBucket = this.perChatBuckets.get(chatId);
|
|
204
|
+
if (chatBucket)
|
|
205
|
+
this.refillBucket(chatBucket, now);
|
|
206
|
+
const wait = Math.max(timeToOneToken(this.globalBucket), chatBucket ? timeToOneToken(chatBucket) : 0);
|
|
207
|
+
return Math.max(1, Math.ceil(wait));
|
|
208
|
+
}
|
|
209
|
+
/** Time (ms) until the soonest queued send could proceed — used instead of busy-polling. */
|
|
210
|
+
nextRefillDelay(now) {
|
|
211
|
+
let wait = Infinity;
|
|
212
|
+
// If the global bucket is the blocker, that gates everything.
|
|
213
|
+
wait = Math.min(wait, timeToOneToken(this.globalBucket));
|
|
214
|
+
for (const it of this.queue) {
|
|
215
|
+
const b = this.perChatBuckets.get(it.chatId);
|
|
216
|
+
// No bucket yet ⇒ a fresh full bucket ⇒ sendable immediately.
|
|
217
|
+
wait = b ? Math.min(wait, timeToOneToken(b)) : 0;
|
|
218
|
+
if (wait === 0)
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (!Number.isFinite(wait))
|
|
222
|
+
wait = 100;
|
|
223
|
+
// Clamp: never spin faster than 10ms, never sleep past 1s (re-checks bound staleness).
|
|
224
|
+
return Math.max(10, Math.min(Math.ceil(wait), 1000));
|
|
225
|
+
}
|
|
226
|
+
/** Evict idle (fully-refilled) buckets when over `maxChats`; never drops an active limit. */
|
|
227
|
+
pruneBuckets(now) {
|
|
228
|
+
if (this.perChatBuckets.size <= this.maxChats)
|
|
229
|
+
return;
|
|
230
|
+
for (const [id, b] of this.perChatBuckets) {
|
|
231
|
+
if (this.perChatBuckets.size <= this.maxChats)
|
|
232
|
+
break;
|
|
233
|
+
this.refillBucket(b, now);
|
|
234
|
+
if (b.tokens >= b.max)
|
|
235
|
+
this.perChatBuckets.delete(id); // idle → recreating it is a no-op
|
|
236
|
+
}
|
|
237
|
+
// All remaining are active; fall back to oldest-first (global bucket still caps totals).
|
|
238
|
+
while (this.perChatBuckets.size > this.maxChats) {
|
|
239
|
+
const oldest = this.perChatBuckets.keys().next();
|
|
240
|
+
if (oldest.done)
|
|
241
|
+
break;
|
|
242
|
+
this.perChatBuckets.delete(oldest.value);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
computeJitter() {
|
|
246
|
+
if (this.jitterMin <= 0 && this.jitterMax <= 0)
|
|
247
|
+
return 0;
|
|
248
|
+
const min = Math.max(this.jitterMin, 0);
|
|
249
|
+
const max = Math.max(this.jitterMax, min);
|
|
250
|
+
return min + Math.random() * (max - min);
|
|
251
|
+
}
|
|
252
|
+
sleep(ms) {
|
|
253
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
|
+
}
|
|
255
|
+
/** Clear all buckets and the queue (e.g. on bot stop). */
|
|
256
|
+
reset() {
|
|
257
|
+
this.generation++;
|
|
258
|
+
this.perChatBuckets.clear();
|
|
259
|
+
this.globalBucket.tokens = this.globalBucket.max;
|
|
260
|
+
this.globalBucket.lastRefill = Date.now();
|
|
261
|
+
// Reject all queued sends (typed: send() must only reject with ProviderError).
|
|
262
|
+
for (const item of this.queue) {
|
|
263
|
+
item.reject(new ProviderError('RateLimiter reset'));
|
|
264
|
+
}
|
|
265
|
+
this.queue = [];
|
|
266
|
+
this.processing = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function timeToOneToken(b) {
|
|
270
|
+
return b.tokens >= 1 ? 0 : (1 - b.tokens) * (b.windowMs / b.max);
|
|
271
|
+
}
|
|
272
|
+
function assertPositive(field, value) {
|
|
273
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
274
|
+
throw new RangeError(`RateLimiter ${field} must be a finite number greater than 0`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function assertNonNegative(field, value) {
|
|
278
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
279
|
+
throw new RangeError(`RateLimiter ${field} must be a finite number greater than or equal to 0`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function assertPositiveInteger(field, value) {
|
|
283
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
284
|
+
throw new RangeError(`RateLimiter ${field} must be a positive integer`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded-size `Map` helpers.
|
|
3
|
+
*
|
|
4
|
+
* Some caches (e.g. BaileysProvider's LID<->PN identity cache) grow by one pair per resolved
|
|
5
|
+
* identity, forever, with no natural eviction point. `putBounded` caps that growth with a
|
|
6
|
+
* simple insertion-order eviction: `Map` preserves insertion order, so once the map exceeds
|
|
7
|
+
* `max` entries we just drop the oldest ones — no timestamps or access-tracking required.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Sets `a -> b` and `b -> a` on `map`, then evicts the oldest entries (in insertion order)
|
|
11
|
+
* until `map.size <= max`. A no-op eviction pass when the map is already within `max`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function putBounded(map: Map<string, string>, a: string, b: string, max: number): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded-size `Map` helpers.
|
|
3
|
+
*
|
|
4
|
+
* Some caches (e.g. BaileysProvider's LID<->PN identity cache) grow by one pair per resolved
|
|
5
|
+
* identity, forever, with no natural eviction point. `putBounded` caps that growth with a
|
|
6
|
+
* simple insertion-order eviction: `Map` preserves insertion order, so once the map exceeds
|
|
7
|
+
* `max` entries we just drop the oldest ones — no timestamps or access-tracking required.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Sets `a -> b` and `b -> a` on `map`, then evicts the oldest entries (in insertion order)
|
|
11
|
+
* until `map.size <= max`. A no-op eviction pass when the map is already within `max`.
|
|
12
|
+
*/
|
|
13
|
+
export function putBounded(map, a, b, max) {
|
|
14
|
+
map.set(a, b);
|
|
15
|
+
map.set(b, a);
|
|
16
|
+
while (map.size > max) {
|
|
17
|
+
const oldest = map.keys().next();
|
|
18
|
+
if (oldest.done)
|
|
19
|
+
break;
|
|
20
|
+
map.delete(oldest.value);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/lib/browser.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** OS presets; each one advertises Chrome on that OS. */
|
|
2
|
+
export type BrowserPreset = 'macOS' | 'ubuntu' | 'windows' | 'appropriate';
|
|
3
|
+
/** A preset, or an explicit `[os, browserName, osVersion]` tuple (caller-owned risk). */
|
|
4
|
+
export type BrowserConfig = BrowserPreset | [string, string, string];
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the `browser` socket option.
|
|
7
|
+
*
|
|
8
|
+
* - `undefined` → `undefined`: the caller must then OMIT the `browser` key so
|
|
9
|
+
* Baileys' default (`Browsers.macOS('Chrome')`) applies. `makeWASocket` spreads
|
|
10
|
+
* the user config over its defaults, so even `browser: undefined` would clobber it.
|
|
11
|
+
* - a preset → `Browsers.<os>('Chrome')`.
|
|
12
|
+
* - a tuple → returned verbatim (escape hatch, e.g. `Browsers.macOS('Desktop')`
|
|
13
|
+
* for full-history desktop emulation). WhatsApp may reject it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveBrowser(browser?: BrowserConfig): [string, string, string] | undefined;
|