@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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Durable media references for the Baileys transport (`IProvider.serializeMediaRef` /
3
+ * `rehydrateMediaRef`).
4
+ *
5
+ * A Baileys `MediaRef.token` is the live `WAMessage`. For the durable inbox it is reduced to
6
+ * what a download needs: the media content (CDN url, direct path, media key and hashes,
7
+ * WITHOUT thumbnails, captions or quoted context) as protobuf bytes, plus the raw message key,
8
+ * which only the re-upload request (`sock.updateMediaMessage`) uses when the CDN copy expired.
9
+ * The key is never used for identity, keying or routing (LID/PN rules are untouched).
10
+ *
11
+ * The stored value is UNTRUSTED when read back: every field is validated and a malformed,
12
+ * oversized, unknown-version or mismatching value is rejected with a `ProviderError`.
13
+ */
14
+ import { normalizeMessageContent, proto, toNumber } from '@whiskeysockets/baileys';
15
+ import { ProviderError } from '@bot-wa/core';
16
+ /** Format version written by {@link serializeBaileysMediaRef}. */
17
+ export const BAILEYS_MEDIA_REF_VERSION = 1;
18
+ /** Upper bound of the base64 protobuf content (a media content without thumbnails is ~300 bytes). */
19
+ const MAX_CONTENT_B64 = 16 * 1024;
20
+ const MEDIA_FIELDS = ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'];
21
+ const FIELD_OF = {
22
+ image: 'imageMessage',
23
+ video: 'videoMessage',
24
+ audio: 'audioMessage',
25
+ voice: 'audioMessage',
26
+ document: 'documentMessage',
27
+ sticker: 'stickerMessage',
28
+ };
29
+ /** What a download (and a re-upload) needs from one media content; nothing else is journaled. */
30
+ const KEPT = [
31
+ 'url', 'directPath', 'mediaKey', 'mediaKeyTimestamp', 'fileEncSha256', 'fileSha256', 'fileLength',
32
+ 'mimetype', 'fileName', 'seconds', 'ptt', 'width', 'height', 'isAnimated',
33
+ ];
34
+ function isViewOnce(message, content) {
35
+ if (content?.viewOnce)
36
+ return true;
37
+ const m = message ?? {};
38
+ return Boolean(m.viewOnceMessage || m.viewOnceMessageV2 || m.viewOnceMessageV2Extension
39
+ || m.ephemeralMessage?.message?.viewOnceMessage || m.ephemeralMessage?.message?.viewOnceMessageV2);
40
+ }
41
+ /**
42
+ * The durable reference of an inbound media message, or `undefined` when it must not (or
43
+ * cannot) be journaled: view-once media (unless `options.viewOnce`), no media key or path,
44
+ * an unexpected shape. Never throws.
45
+ */
46
+ export function serializeBaileysMediaRef(ref, options = {}) {
47
+ try {
48
+ const message = ref.token;
49
+ const field = FIELD_OF[ref.kind];
50
+ if (!field || !message || typeof message !== 'object' || !message.key?.id || !message.key.remoteJid)
51
+ return undefined;
52
+ const inner = normalizeMessageContent(message.message);
53
+ const content = inner?.[field];
54
+ if (!content || !content.mediaKey || !(content.directPath || content.url))
55
+ return undefined;
56
+ if (isViewOnce(message.message, content) && options.viewOnce !== true)
57
+ return undefined;
58
+ const kept = {};
59
+ for (const name of KEPT)
60
+ if (content[name] !== undefined && content[name] !== null)
61
+ kept[name] = content[name];
62
+ const bytes = proto.Message.encode(proto.Message.fromObject({ [field]: kept })).finish();
63
+ const m = Buffer.from(bytes).toString('base64');
64
+ if (m.length > MAX_CONTENT_B64)
65
+ return undefined;
66
+ const k = message.key;
67
+ const key = {
68
+ remoteJid: k.remoteJid,
69
+ id: k.id,
70
+ ...(typeof k.fromMe === 'boolean' ? { fromMe: k.fromMe } : {}),
71
+ ...(k.participant ? { participant: k.participant } : {}),
72
+ };
73
+ const t = message.messageTimestamp === undefined || message.messageTimestamp === null ? undefined : toNumber(message.messageTimestamp);
74
+ const data = { key, m, ...(typeof t === 'number' && Number.isFinite(t) ? { t } : {}) };
75
+ return { v: BAILEYS_MEDIA_REF_VERSION, data: data };
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ const JID = /^[\w.:+-]{1,128}@[\w.-]{1,64}$/;
82
+ const MESSAGE_ID = /^[\w.:-]{1,128}$/;
83
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
84
+ function invalid(why) {
85
+ return new ProviderError(`Invalid Baileys media reference: ${why}`);
86
+ }
87
+ /**
88
+ * Decode and validate a journaled reference into the message key and a `WAMessage` holding
89
+ * only the media content. @throws {ProviderError} on anything unexpected.
90
+ */
91
+ export function parseBaileysMediaRef(stored, kind) {
92
+ if (!stored || typeof stored !== 'object')
93
+ throw invalid('not an object');
94
+ if (stored.v !== BAILEYS_MEDIA_REF_VERSION)
95
+ throw invalid(`unknown version ${String(stored.v)}`);
96
+ const data = stored.data;
97
+ if (!data || typeof data !== 'object' || Array.isArray(data))
98
+ throw invalid('no data');
99
+ const key = data.key;
100
+ if (!key || typeof key !== 'object')
101
+ throw invalid('no key');
102
+ if (typeof key.remoteJid !== 'string' || !JID.test(key.remoteJid))
103
+ throw invalid('bad remoteJid');
104
+ if (typeof key.id !== 'string' || !MESSAGE_ID.test(key.id))
105
+ throw invalid('bad id');
106
+ if (key.fromMe !== undefined && typeof key.fromMe !== 'boolean')
107
+ throw invalid('bad fromMe');
108
+ if (key.participant !== undefined && (typeof key.participant !== 'string' || !JID.test(key.participant)))
109
+ throw invalid('bad participant');
110
+ if (data.t !== undefined && (typeof data.t !== 'number' || !Number.isFinite(data.t) || data.t < 0))
111
+ throw invalid('bad timestamp');
112
+ if (typeof data.m !== 'string' || data.m.length === 0 || data.m.length > MAX_CONTENT_B64 || !BASE64.test(data.m))
113
+ throw invalid('bad content');
114
+ let decoded;
115
+ try {
116
+ decoded = proto.Message.decode(Buffer.from(data.m, 'base64'));
117
+ }
118
+ catch {
119
+ throw invalid('undecodable content');
120
+ }
121
+ const present = MEDIA_FIELDS.filter((f) => decoded[f] !== undefined && decoded[f] !== null);
122
+ const field = FIELD_OF[kind];
123
+ if (present.length !== 1 || present[0] !== field)
124
+ throw invalid('media content does not match the message kind');
125
+ const content = decoded[field];
126
+ if (!content?.mediaKey?.length)
127
+ throw invalid('no media key');
128
+ const waKey = {
129
+ remoteJid: key.remoteJid,
130
+ id: key.id,
131
+ ...(key.fromMe !== undefined ? { fromMe: key.fromMe } : {}),
132
+ ...(key.participant !== undefined ? { participant: key.participant } : {}),
133
+ };
134
+ const message = { key: waKey, message: decoded, ...(data.t !== undefined ? { messageTimestamp: data.t } : {}) };
135
+ return { key: waKey, message };
136
+ }
package/lib/polls.d.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { type proto, type WAMessage } from '@whiskeysockets/baileys';
2
+ /** Default number of polls remembered; the oldest idle poll is evicted first. */
3
+ export declare const POLL_CACHE_MAX = 500;
4
+ /** Default number of counted vote message ids remembered to drop redeliveries. */
5
+ export declare const VOTE_IDS_MAX = 5000;
6
+ export interface PollOption {
7
+ name: string;
8
+ /** Hex SHA-256 of the option name: what a decrypted vote selects. */
9
+ hash: string;
10
+ }
11
+ /**
12
+ * The JSON-safe record a {@link PollStore} keeps for one poll: what a later
13
+ * process needs to decrypt, bind and tally its votes. No message bodies beyond
14
+ * the poll question and option names.
15
+ */
16
+ export interface StoredPoll {
17
+ /** The poll message id (the store key). */
18
+ id: string;
19
+ /** The poll message key: its chat and creator, as Baileys delivered it. */
20
+ key: {
21
+ remoteJid: string;
22
+ remoteJidAlt?: string;
23
+ participant?: string;
24
+ participantAlt?: string;
25
+ fromMe: boolean;
26
+ };
27
+ /** The poll question. */
28
+ name: string;
29
+ /** Base64 of the poll's 32-byte messageSecret (the vote decryption key material). */
30
+ secret: string;
31
+ /** Option names with their hex SHA-256, in the poll's order. */
32
+ options: PollOption[];
33
+ /** Latest selection per canonical voter id (empty `hashes` = retracted). */
34
+ votes: Array<{
35
+ voter: string;
36
+ hashes: string[];
37
+ at: number;
38
+ }>;
39
+ /** Poll creation time, unix seconds, when known. */
40
+ timestamp?: number;
41
+ }
42
+ /**
43
+ * Pluggable persistence for polls, so votes cast after a restart can still be
44
+ * decrypted and tallied. Keyed by poll message id. The provider calls `set`
45
+ * when it first sees a poll and after every counted vote, `get` when a vote
46
+ * names a poll missing from its in-memory cache (or a new poll reuses an id),
47
+ * and `delete` when a stored entry is malformed. Retention (TTL, cleanup) is up
48
+ * to the host. A rejecting call is logged and never breaks sends or ingress.
49
+ * The entry holds the poll's secret: keep the store as private as the session.
50
+ */
51
+ export interface PollStore {
52
+ get(pollId: string): Promise<StoredPoll | null | undefined>;
53
+ set(pollId: string, poll: StoredPoll): Promise<void>;
54
+ delete(pollId: string): Promise<void>;
55
+ }
56
+ export interface TrackedPoll {
57
+ id: string;
58
+ /** The poll creation message (the `native` of a poll-vote `ref`). */
59
+ message: WAMessage;
60
+ secret: Uint8Array;
61
+ options: PollOption[];
62
+ /** Latest selection per canonical voter id. An empty list is a retracted vote. */
63
+ votes: Map<string, {
64
+ hashes: string[];
65
+ at: number;
66
+ }>;
67
+ }
68
+ /** The poll creation inside a message (any version, unwrapped), or `undefined`. */
69
+ export declare function pollCreationOf(message: WAMessage): {
70
+ name: string;
71
+ options: string[];
72
+ secret?: Uint8Array;
73
+ } | undefined;
74
+ /** The encrypted vote inside a message (unwrapped), or `undefined`. */
75
+ export declare function pollUpdateOf(message: WAMessage): proto.Message.IPollUpdateMessage | undefined;
76
+ /** Bounded, insertion-ordered cache of the polls whose votes can be decrypted. */
77
+ export declare class PollTracker {
78
+ private readonly max;
79
+ private readonly maxVoteIds;
80
+ private readonly polls;
81
+ /** `<pollId> <voteMessageId>` of the votes already counted, oldest first. */
82
+ private readonly votesSeen;
83
+ constructor(max?: number, maxVoteIds?: number);
84
+ get size(): number;
85
+ /**
86
+ * Remember `message` if it is a poll creation carrying its messageSecret.
87
+ * Returns whether it is tracked. Seeing the same poll again (the `send()`
88
+ * result, then Baileys' `append` echo) keeps the tallies collected so far. A
89
+ * different poll reusing a tracked id (ids are chosen by the sending client)
90
+ * never replaces it; its votes fail the caller's creator check.
91
+ */
92
+ remember(message: WAMessage): boolean;
93
+ /**
94
+ * Like {@link remember}, returning the tracked poll and whether this call
95
+ * added it (`isNew`), so a caller can persist new polls only.
96
+ */
97
+ track(message: WAMessage): {
98
+ poll: TrackedPoll;
99
+ isNew: boolean;
100
+ } | undefined;
101
+ /** Insert an already-built poll (e.g. loaded from a {@link PollStore}), evicting the oldest. */
102
+ adopt(poll: TrackedPoll): void;
103
+ get(id: string): TrackedPoll | undefined;
104
+ /** Mark a poll as recently used so active polls outlive idle ones. */
105
+ touch(poll: TrackedPoll): void;
106
+ /**
107
+ * Whether the vote message `voteId` was already counted for `poll`. Baileys
108
+ * re-emits offline deliveries as `append` upserts, so the same vote message
109
+ * can arrive twice.
110
+ */
111
+ hasVote(poll: TrackedPoll, voteId: string): boolean;
112
+ /** Record that `voteId` was counted for `poll`; the oldest ids are forgotten first. */
113
+ markVote(poll: TrackedPoll, voteId: string): void;
114
+ }
115
+ /**
116
+ * Decrypt an encrypted vote, trying every creator × voter JID candidate (both
117
+ * normalised, device-free). Returns the selected option hashes (hex), or
118
+ * `undefined` when no pair authenticates: wrong secret, or a JID form we do not
119
+ * know.
120
+ */
121
+ export declare function decryptVote(vote: proto.Message.IPollEncValue | null | undefined, poll: TrackedPoll, creators: string[], voters: string[]): {
122
+ hashes: string[];
123
+ } | undefined;
124
+ /**
125
+ * Store `voterId`'s latest selection. Earlier entries whose voter has since been
126
+ * canonicalised to another id (a bare LID whose PN was learned) are merged first,
127
+ * so one person is counted once. Returns `false`, changing nothing, for a vote
128
+ * older than the one already stored. A vote with the same timestamp replaces it:
129
+ * it is another vote message sent in the same millisecond, because redeliveries
130
+ * of an already counted message are dropped earlier ({@link PollTracker.hasVote}).
131
+ */
132
+ export declare function recordVote(poll: TrackedPoll, voterId: string, hashes: string[], at: number, canonical: (id: string) => string): boolean;
133
+ /** Per-option tallies in the poll's option order, with canonical voter ids. */
134
+ export declare function tally(poll: TrackedPoll): Array<{
135
+ name: string;
136
+ votes: number;
137
+ voters: string[];
138
+ }>;
139
+ /** The JSON-safe {@link StoredPoll} for a tracked poll. */
140
+ export declare function toStoredPoll(poll: TrackedPoll): StoredPoll;
141
+ /**
142
+ * Rebuild a tracked poll from a stored record, or `undefined` when the record
143
+ * is malformed (wrong id, missing chat, secret not 32 bytes, bad options or
144
+ * votes). `message` is a minimal poll creation message carrying the stored key,
145
+ * usable as the `native` of a poll-vote ref (e.g. to quote the poll).
146
+ */
147
+ export declare function fromStoredPoll(id: string, stored: unknown): TrackedPoll | undefined;
package/lib/polls.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Poll tracking and vote decryption for Baileys 7.
3
+ *
4
+ * WhatsApp encrypts every poll vote end to end with a key derived from the
5
+ * poll's 32-byte `messageContextInfo.messageSecret`, the poll id, the poll
6
+ * creator's JID and the voter's JID. Baileys 7 ships its own decryption
7
+ * commented out (Utils/process-message.js), so `messages.update` never carries
8
+ * `pollUpdates`: a vote only arrives as an encrypted `pollUpdateMessage` upsert.
9
+ *
10
+ * {@link PollTracker} remembers the polls this process sees (with their secret
11
+ * and option hashes) and the vote messages already counted, {@link decryptVote}
12
+ * tries the creator/voter JID forms a client may have used (LID and PN differ
13
+ * per addressing mode; AES-GCM rejects a wrong pair, so trying them is safe), and
14
+ * {@link recordVote}/{@link tally} keep the latest selection per voter and
15
+ * aggregate it into per-option counts.
16
+ *
17
+ * The key derivation does not cover the chat, and every member of a chat knows
18
+ * its polls' ids and secrets, so a decryptable vote is not proof that it belongs
19
+ * to the poll it names: the caller must also bind the vote to the poll's chat and
20
+ * creator (see BaileysProvider.onPollVote).
21
+ */
22
+ import { createHash } from 'node:crypto';
23
+ import { decryptPollVote, normalizeMessageContent } from '@whiskeysockets/baileys';
24
+ /** Default number of polls remembered; the oldest idle poll is evicted first. */
25
+ export const POLL_CACHE_MAX = 500;
26
+ /** Default number of counted vote message ids remembered to drop redeliveries. */
27
+ export const VOTE_IDS_MAX = 5000;
28
+ /** The poll creation inside a message (any version, unwrapped), or `undefined`. */
29
+ export function pollCreationOf(message) {
30
+ const content = normalizeMessageContent(message.message);
31
+ const creation = content?.pollCreationMessage ?? content?.pollCreationMessageV2 ?? content?.pollCreationMessageV3;
32
+ if (!creation)
33
+ return undefined;
34
+ const secret = content?.messageContextInfo?.messageSecret ?? message.message?.messageContextInfo?.messageSecret;
35
+ return {
36
+ name: creation.name ?? '',
37
+ options: (creation.options ?? []).map((o) => o.optionName ?? ''),
38
+ secret: secret ?? undefined,
39
+ };
40
+ }
41
+ /** The encrypted vote inside a message (unwrapped), or `undefined`. */
42
+ export function pollUpdateOf(message) {
43
+ return normalizeMessageContent(message.message)?.pollUpdateMessage ?? undefined;
44
+ }
45
+ const optionHash = (name) => createHash('sha256').update(Buffer.from(name)).digest('hex');
46
+ /** Bounded, insertion-ordered cache of the polls whose votes can be decrypted. */
47
+ export class PollTracker {
48
+ max;
49
+ maxVoteIds;
50
+ polls = new Map();
51
+ /** `<pollId> <voteMessageId>` of the votes already counted, oldest first. */
52
+ votesSeen = new Set();
53
+ constructor(max = POLL_CACHE_MAX, maxVoteIds = VOTE_IDS_MAX) {
54
+ this.max = max;
55
+ this.maxVoteIds = maxVoteIds;
56
+ }
57
+ get size() { return this.polls.size; }
58
+ /**
59
+ * Remember `message` if it is a poll creation carrying its messageSecret.
60
+ * Returns whether it is tracked. Seeing the same poll again (the `send()`
61
+ * result, then Baileys' `append` echo) keeps the tallies collected so far. A
62
+ * different poll reusing a tracked id (ids are chosen by the sending client)
63
+ * never replaces it; its votes fail the caller's creator check.
64
+ */
65
+ remember(message) {
66
+ return !!this.track(message);
67
+ }
68
+ /**
69
+ * Like {@link remember}, returning the tracked poll and whether this call
70
+ * added it (`isNew`), so a caller can persist new polls only.
71
+ */
72
+ track(message) {
73
+ const id = message.key?.id;
74
+ if (!id)
75
+ return undefined;
76
+ const existing = this.polls.get(id);
77
+ if (existing)
78
+ return { poll: existing, isNew: false };
79
+ const creation = pollCreationOf(message);
80
+ if (!creation?.secret?.length)
81
+ return undefined;
82
+ const poll = {
83
+ id,
84
+ message,
85
+ secret: creation.secret,
86
+ options: creation.options.map((name) => ({ name, hash: optionHash(name) })),
87
+ votes: new Map(),
88
+ };
89
+ this.adopt(poll);
90
+ return { poll, isNew: true };
91
+ }
92
+ /** Insert an already-built poll (e.g. loaded from a {@link PollStore}), evicting the oldest. */
93
+ adopt(poll) {
94
+ this.polls.delete(poll.id);
95
+ this.polls.set(poll.id, poll);
96
+ while (this.polls.size > this.max) {
97
+ const oldest = this.polls.keys().next();
98
+ if (oldest.done)
99
+ break;
100
+ this.polls.delete(oldest.value);
101
+ }
102
+ }
103
+ get(id) { return this.polls.get(id); }
104
+ /** Mark a poll as recently used so active polls outlive idle ones. */
105
+ touch(poll) {
106
+ if (this.polls.delete(poll.id))
107
+ this.polls.set(poll.id, poll);
108
+ }
109
+ /**
110
+ * Whether the vote message `voteId` was already counted for `poll`. Baileys
111
+ * re-emits offline deliveries as `append` upserts, so the same vote message
112
+ * can arrive twice.
113
+ */
114
+ hasVote(poll, voteId) {
115
+ return this.votesSeen.has(`${poll.id} ${voteId}`);
116
+ }
117
+ /** Record that `voteId` was counted for `poll`; the oldest ids are forgotten first. */
118
+ markVote(poll, voteId) {
119
+ this.votesSeen.add(`${poll.id} ${voteId}`);
120
+ while (this.votesSeen.size > this.maxVoteIds) {
121
+ const oldest = this.votesSeen.values().next();
122
+ if (oldest.done)
123
+ break;
124
+ this.votesSeen.delete(oldest.value);
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Decrypt an encrypted vote, trying every creator × voter JID candidate (both
130
+ * normalised, device-free). Returns the selected option hashes (hex), or
131
+ * `undefined` when no pair authenticates: wrong secret, or a JID form we do not
132
+ * know.
133
+ */
134
+ export function decryptVote(vote, poll, creators, voters) {
135
+ if (!vote?.encPayload?.length || !vote.encIv?.length)
136
+ return undefined;
137
+ for (const pollCreatorJid of creators) {
138
+ for (const voterJid of voters) {
139
+ try {
140
+ const decoded = decryptPollVote(vote, { pollCreatorJid, pollMsgId: poll.id, pollEncKey: poll.secret, voterJid });
141
+ return { hashes: (decoded.selectedOptions ?? []).map((h) => Buffer.from(h).toString('hex')) };
142
+ }
143
+ catch {
144
+ // Wrong JID pair: GCM authentication fails. Try the next candidate.
145
+ }
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Store `voterId`'s latest selection. Earlier entries whose voter has since been
152
+ * canonicalised to another id (a bare LID whose PN was learned) are merged first,
153
+ * so one person is counted once. Returns `false`, changing nothing, for a vote
154
+ * older than the one already stored. A vote with the same timestamp replaces it:
155
+ * it is another vote message sent in the same millisecond, because redeliveries
156
+ * of an already counted message are dropped earlier ({@link PollTracker.hasVote}).
157
+ */
158
+ export function recordVote(poll, voterId, hashes, at, canonical) {
159
+ for (const [id, entry] of [...poll.votes]) {
160
+ const target = canonical(id);
161
+ if (!target || target === id)
162
+ continue;
163
+ poll.votes.delete(id);
164
+ const existing = poll.votes.get(target);
165
+ if (!existing || existing.at <= entry.at)
166
+ poll.votes.set(target, entry);
167
+ }
168
+ const previous = poll.votes.get(voterId);
169
+ if (previous && at < previous.at)
170
+ return false;
171
+ poll.votes.set(voterId, { hashes, at });
172
+ return true;
173
+ }
174
+ /** Per-option tallies in the poll's option order, with canonical voter ids. */
175
+ export function tally(poll) {
176
+ return poll.options.map((option) => {
177
+ const voters = [...poll.votes].filter(([, v]) => v.hashes.includes(option.hash)).map(([id]) => id);
178
+ return { name: option.name, votes: voters.length, voters };
179
+ });
180
+ }
181
+ /** Optional key fields kept beside `remoteJid`/`fromMe`: the twins and the group creator. */
182
+ const KEY_FIELDS = ['remoteJidAlt', 'participant', 'participantAlt'];
183
+ /** The JSON-safe {@link StoredPoll} for a tracked poll. */
184
+ export function toStoredPoll(poll) {
185
+ const raw = poll.message.key ?? {};
186
+ const key = { remoteJid: raw.remoteJid ?? '', fromMe: !!raw.fromMe };
187
+ for (const field of KEY_FIELDS) {
188
+ const value = raw[field];
189
+ if (typeof value === 'string' && value)
190
+ key[field] = value;
191
+ }
192
+ const timestamp = Number(poll.message.messageTimestamp ?? 0);
193
+ return {
194
+ id: poll.id,
195
+ key,
196
+ name: pollCreationOf(poll.message)?.name ?? '',
197
+ secret: Buffer.from(poll.secret).toString('base64'),
198
+ options: poll.options.map((o) => ({ name: o.name, hash: o.hash })),
199
+ votes: [...poll.votes].map(([voter, v]) => ({ voter, hashes: [...v.hashes], at: v.at })),
200
+ ...(Number.isFinite(timestamp) && timestamp > 0 ? { timestamp } : {}),
201
+ };
202
+ }
203
+ const isString = (v) => typeof v === 'string';
204
+ const HEX_HASH = /^[0-9a-f]{64}$/;
205
+ /**
206
+ * Rebuild a tracked poll from a stored record, or `undefined` when the record
207
+ * is malformed (wrong id, missing chat, secret not 32 bytes, bad options or
208
+ * votes). `message` is a minimal poll creation message carrying the stored key,
209
+ * usable as the `native` of a poll-vote ref (e.g. to quote the poll).
210
+ */
211
+ export function fromStoredPoll(id, stored) {
212
+ if (!stored || typeof stored !== 'object')
213
+ return undefined;
214
+ const s = stored;
215
+ if (s.id !== id || !s.key || typeof s.key !== 'object' || !isString(s.key.remoteJid) || !s.key.remoteJid)
216
+ return undefined;
217
+ if (!isString(s.secret))
218
+ return undefined;
219
+ const secret = new Uint8Array(Buffer.from(s.secret, 'base64'));
220
+ if (secret.length !== 32)
221
+ return undefined;
222
+ if (!Array.isArray(s.options) || !s.options.every((o) => o && isString(o.name) && isString(o.hash) && HEX_HASH.test(o.hash)))
223
+ return undefined;
224
+ const votes = new Map();
225
+ for (const v of Array.isArray(s.votes) ? s.votes : []) {
226
+ if (!v || !isString(v.voter) || !Array.isArray(v.hashes) || !v.hashes.every(isString) || typeof v.at !== 'number')
227
+ return undefined;
228
+ votes.set(v.voter, { hashes: [...v.hashes], at: v.at });
229
+ }
230
+ const key = { remoteJid: s.key.remoteJid, fromMe: !!s.key.fromMe, id };
231
+ for (const field of KEY_FIELDS)
232
+ if (isString(s.key[field]) && s.key[field])
233
+ key[field] = s.key[field];
234
+ const message = {
235
+ key,
236
+ message: {
237
+ pollCreationMessage: { name: isString(s.name) ? s.name : '', options: s.options.map((o) => ({ optionName: o.name })) },
238
+ messageContextInfo: { messageSecret: secret },
239
+ },
240
+ ...(typeof s.timestamp === 'number' ? { messageTimestamp: s.timestamp } : {}),
241
+ };
242
+ return { id, message, secret, options: s.options.map((o) => ({ name: o.name, hash: o.hash })), votes };
243
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reconnect policy — pure decision logic for connection-close handling.
3
+ *
4
+ * Extracted from BaileysProvider so it can be unit-tested without a socket.
5
+ * Maps a Baileys {@link DisconnectReason} status code to the action the
6
+ * provider should take, and computes the backoff delay between attempts.
7
+ */
8
+ export type DisconnectAction =
9
+ /** Session is invalid — wipe auth state and start fresh. */
10
+ 'wipe'
11
+ /** Do NOT reconnect — another client owns the session, or we're forbidden. */
12
+ | 'stop'
13
+ /** Reconnect immediately (normal post-pairing restart). */
14
+ | 'reconnect-now'
15
+ /** Reconnect after an exponential backoff delay. */
16
+ | 'reconnect-backoff';
17
+ /**
18
+ * Decide what to do when the socket closes, from the disconnect status code.
19
+ *
20
+ * - `loggedOut` / `badSession` / `multideviceMismatch` → `wipe`
21
+ * - `connectionReplaced` (440, "conflict": another device/instance took the
22
+ * session) / `forbidden` (403) → `stop` — reconnecting just fights the other
23
+ * client forever and risks a ban
24
+ * - `restartRequired` (515) → `reconnect-now` (expected during pairing)
25
+ * - everything else (timeouts, transient closes, service unavailable) →
26
+ * `reconnect-backoff`
27
+ */
28
+ export declare function classifyDisconnect(statusCode: number | undefined): DisconnectAction;
29
+ /**
30
+ * Exponential backoff with a cap. `attempt` is 0-based (first retry = 0).
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * backoffDelay(0) // 1000
35
+ * backoffDelay(1) // 2000
36
+ * backoffDelay(5) // 30000 (capped)
37
+ * ```
38
+ */
39
+ export declare function backoffDelay(attempt: number, base?: number, cap?: number): number;
40
+ /** Max consecutive backoff reconnect attempts before giving up. */
41
+ export declare const MAX_RECONNECT_ATTEMPTS = 10;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Reconnect policy — pure decision logic for connection-close handling.
3
+ *
4
+ * Extracted from BaileysProvider so it can be unit-tested without a socket.
5
+ * Maps a Baileys {@link DisconnectReason} status code to the action the
6
+ * provider should take, and computes the backoff delay between attempts.
7
+ */
8
+ import { DisconnectReason } from '@whiskeysockets/baileys';
9
+ /**
10
+ * Decide what to do when the socket closes, from the disconnect status code.
11
+ *
12
+ * - `loggedOut` / `badSession` / `multideviceMismatch` → `wipe`
13
+ * - `connectionReplaced` (440, "conflict": another device/instance took the
14
+ * session) / `forbidden` (403) → `stop` — reconnecting just fights the other
15
+ * client forever and risks a ban
16
+ * - `restartRequired` (515) → `reconnect-now` (expected during pairing)
17
+ * - everything else (timeouts, transient closes, service unavailable) →
18
+ * `reconnect-backoff`
19
+ */
20
+ export function classifyDisconnect(statusCode) {
21
+ switch (statusCode) {
22
+ case DisconnectReason.loggedOut:
23
+ case DisconnectReason.badSession:
24
+ case DisconnectReason.multideviceMismatch:
25
+ return 'wipe';
26
+ case DisconnectReason.connectionReplaced:
27
+ case DisconnectReason.forbidden:
28
+ return 'stop';
29
+ case DisconnectReason.restartRequired:
30
+ return 'reconnect-now';
31
+ default:
32
+ return 'reconnect-backoff';
33
+ }
34
+ }
35
+ /**
36
+ * Exponential backoff with a cap. `attempt` is 0-based (first retry = 0).
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * backoffDelay(0) // 1000
41
+ * backoffDelay(1) // 2000
42
+ * backoffDelay(5) // 30000 (capped)
43
+ * ```
44
+ */
45
+ export function backoffDelay(attempt, base = 1000, cap = 30_000) {
46
+ if (attempt < 0)
47
+ attempt = 0;
48
+ return Math.min(base * 2 ** attempt, cap);
49
+ }
50
+ /** Max consecutive backoff reconnect attempts before giving up. */
51
+ export const MAX_RECONNECT_ATTEMPTS = 10;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolve a session directory without allowing an identifier to escape its
3
+ * configured root. Session identifiers are deliberately opaque: callers that
4
+ * need hierarchy should configure a different `sessionsDir`, not put path
5
+ * separators in `session`.
6
+ */
7
+ export declare function resolveSessionDirectory(sessionsDir?: string, session?: string): string;
8
+ /**
9
+ * Reject an existing session symlink and verify real-path containment. This
10
+ * prevents auth reads/writes from being redirected outside `sessionsDir`.
11
+ */
12
+ export declare function assertSessionDirectorySafe(sessionsDir?: string, session?: string): string;
13
+ /** Remove exactly one validated session directory. */
14
+ export declare function removeSessionDirectory(sessionsDir?: string, session?: string): void;