@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,52 @@
1
+ import { existsSync, lstatSync, realpathSync, rmSync, } from 'node:fs';
2
+ import { isAbsolute, parse, relative, resolve, sep, } from 'node:path';
3
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
4
+ function isContained(base, target) {
5
+ const rel = relative(base, target);
6
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
7
+ }
8
+ /**
9
+ * Resolve a session directory without allowing an identifier to escape its
10
+ * configured root. Session identifiers are deliberately opaque: callers that
11
+ * need hierarchy should configure a different `sessionsDir`, not put path
12
+ * separators in `session`.
13
+ */
14
+ export function resolveSessionDirectory(sessionsDir = './sessions', session = 'default') {
15
+ if (!SESSION_ID.test(session) || session === '.' || session === '..') {
16
+ throw new Error('Invalid Baileys session id. Use 1-128 letters, numbers, dots, underscores or hyphens; path segments are not allowed.');
17
+ }
18
+ const base = resolve(sessionsDir);
19
+ if (base === parse(base).root) {
20
+ throw new Error('Baileys sessionsDir must not be a filesystem root');
21
+ }
22
+ const target = resolve(base, session);
23
+ if (!isContained(base, target)) {
24
+ throw new Error('Baileys session directory escaped its configured root');
25
+ }
26
+ return target;
27
+ }
28
+ /**
29
+ * Reject an existing session symlink and verify real-path containment. This
30
+ * prevents auth reads/writes from being redirected outside `sessionsDir`.
31
+ */
32
+ export function assertSessionDirectorySafe(sessionsDir = './sessions', session = 'default') {
33
+ const target = resolveSessionDirectory(sessionsDir, session);
34
+ if (!existsSync(target))
35
+ return target;
36
+ if (lstatSync(target).isSymbolicLink()) {
37
+ throw new Error('Baileys session directory must not be a symbolic link');
38
+ }
39
+ const base = resolve(sessionsDir);
40
+ const realBase = existsSync(base) ? realpathSync(base) : base;
41
+ const realTarget = realpathSync(target);
42
+ if (!isContained(realBase, realTarget)) {
43
+ throw new Error('Baileys session directory escaped its configured root through a symbolic link');
44
+ }
45
+ return target;
46
+ }
47
+ /** Remove exactly one validated session directory. */
48
+ export function removeSessionDirectory(sessionsDir = './sessions', session = 'default') {
49
+ const target = assertSessionDirectorySafe(sessionsDir, session);
50
+ if (existsSync(target))
51
+ rmSync(target, { recursive: true, force: true });
52
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * toCallEvent — project a Baileys `WACallEvent` into a transport-neutral
3
+ * {@link CallEvent}.
4
+ *
5
+ * Identity is NOT resolved here: the provider canonicalises the caller (and
6
+ * the group JID) through the same LID/PN rule as every other event
7
+ * (`IdentityStore.resolve`, seeded from `getPNForLID` for bare LIDs) and passes
8
+ * the results in. This module only adds the call-specific shape: which chat a
9
+ * call belongs to across its status updates, and the `isVideo`/`offline` flags.
10
+ */
11
+ import type { WACallEvent } from '@whiskeysockets/baileys';
12
+ import type { CallEvent, ChatId, UserId } from '@bot-wa/core';
13
+ /**
14
+ * @param from the caller's canonical id (same id their messages carry as `senderId`).
15
+ * @param groupId the canonical group id when this update carries `groupJid` (Baileys only
16
+ * sends it on the offer).
17
+ * @param groups call id → group id, so later updates of a group call stay in the group.
18
+ */
19
+ export declare function toCallEvent(call: WACallEvent, from: UserId, groupId: ChatId | undefined, groups: Map<string, ChatId>): CallEvent;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * toCallEvent — project a Baileys `WACallEvent` into a transport-neutral
3
+ * {@link CallEvent}.
4
+ *
5
+ * Identity is NOT resolved here: the provider canonicalises the caller (and
6
+ * the group JID) through the same LID/PN rule as every other event
7
+ * (`IdentityStore.resolve`, seeded from `getPNForLID` for bare LIDs) and passes
8
+ * the results in. This module only adds the call-specific shape: which chat a
9
+ * call belongs to across its status updates, and the `isVideo`/`offline` flags.
10
+ */
11
+ /** Cap on remembered group calls (one entry per call in progress). */
12
+ const CALL_GROUPS_MAX = 500;
13
+ /** Statuses after which Baileys forgets a call (mirrors its own offer cache). */
14
+ const FINAL_STATUSES = new Set(['reject', 'accept', 'timeout', 'terminate']);
15
+ /**
16
+ * @param from the caller's canonical id (same id their messages carry as `senderId`).
17
+ * @param groupId the canonical group id when this update carries `groupJid` (Baileys only
18
+ * sends it on the offer).
19
+ * @param groups call id → group id, so later updates of a group call stay in the group.
20
+ */
21
+ export function toCallEvent(call, from, groupId, groups) {
22
+ if (groupId) {
23
+ groups.set(call.id, groupId);
24
+ if (groups.size > CALL_GROUPS_MAX) {
25
+ const oldest = groups.keys().next();
26
+ if (!oldest.done)
27
+ groups.delete(oldest.value);
28
+ }
29
+ }
30
+ const group = groups.get(call.id);
31
+ if (FINAL_STATUSES.has(String(call.status)))
32
+ groups.delete(call.id);
33
+ const event = {
34
+ id: call.id,
35
+ from,
36
+ status: String(call.status),
37
+ // A group call whose group is unknown (its offer was never seen) falls back to the DM.
38
+ chatId: group ?? from,
39
+ isGroup: group !== undefined,
40
+ };
41
+ if (call.isVideo !== undefined)
42
+ event.isVideo = call.isVideo;
43
+ if (call.offline !== undefined)
44
+ event.offline = call.offline;
45
+ return event;
46
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * toInbound — normalize a raw Baileys `WAMessage` into a transport-neutral
3
+ * {@link InboundMessage}. All proto parsing and LID/PN identity canonicalization
4
+ * lives here, INSIDE the provider, so core never sees a WhatsApp concept. The
5
+ * emitted `chatId`/`senderId` are already stable (see {@link IdentityStore}).
6
+ */
7
+ import { type WAMessage, type proto } from '@whiskeysockets/baileys';
8
+ import { type InboundMessage } from '@bot-wa/core';
9
+ import { type IdentityStore } from './identity.js';
10
+ type ContextInfo = proto.IContextInfo;
11
+ /**
12
+ * The reply/mention context of a message, wherever its content type keeps it
13
+ * (`extendedTextMessage`, `imageMessage`, `videoMessage`, `documentMessage`…).
14
+ * Wrapped content (disappearing mode, view once, document with caption) is
15
+ * unwrapped first.
16
+ */
17
+ export declare function contextInfoOf(content: proto.IMessage | null | undefined): ContextInfo | undefined;
18
+ export declare function toInbound(message: WAMessage, identity: IdentityStore): InboundMessage<WAMessage>;
19
+ export {};
@@ -0,0 +1,177 @@
1
+ /**
2
+ * toInbound — normalize a raw Baileys `WAMessage` into a transport-neutral
3
+ * {@link InboundMessage}. All proto parsing and LID/PN identity canonicalization
4
+ * lives here, INSIDE the provider, so core never sees a WhatsApp concept. The
5
+ * emitted `chatId`/`senderId` are already stable (see {@link IdentityStore}).
6
+ */
7
+ import { normalizeMessageContent, toNumber } from '@whiskeysockets/baileys';
8
+ import { toInboundLocation } from '@bot-wa/core';
9
+ import { isPnUser, isLidUser, normalize } from './jid.js';
10
+ import { canonicalKey } from './identity.js';
11
+ const MEDIA_KINDS = new Set(['image', 'video', 'audio', 'voice', 'document', 'sticker']);
12
+ /**
13
+ * The reply/mention context of a message, wherever its content type keeps it
14
+ * (`extendedTextMessage`, `imageMessage`, `videoMessage`, `documentMessage`…).
15
+ * Wrapped content (disappearing mode, view once, document with caption) is
16
+ * unwrapped first.
17
+ */
18
+ export function contextInfoOf(content) {
19
+ const m = normalizeMessageContent(content);
20
+ if (!m)
21
+ return undefined;
22
+ for (const part of Object.values(m)) {
23
+ if (part && typeof part === 'object' && 'contextInfo' in part) {
24
+ const ctx = part.contextInfo;
25
+ if (ctx)
26
+ return ctx;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+ /** Plain text of a (quoted) message: its text, or the caption of its media. */
32
+ function textOf(content) {
33
+ const m = normalizeMessageContent(content);
34
+ if (!m)
35
+ return '';
36
+ return (m.conversation ??
37
+ m.extendedTextMessage?.text ??
38
+ m.imageMessage?.caption ??
39
+ m.videoMessage?.caption ??
40
+ m.documentMessage?.caption ??
41
+ '');
42
+ }
43
+ /** Epoch seconds from a `number | Long | null` protobuf timestamp (0 when absent). */
44
+ function timestampOf(value) {
45
+ const n = Number(toNumber(value ?? 0));
46
+ return Number.isFinite(n) ? n : 0;
47
+ }
48
+ export function toInbound(message, identity) {
49
+ const key = (message.key ?? { remoteJid: '', fromMe: false });
50
+ // Learn LID↔PN pairings this key exposes, then canonicalize to stable PN ids.
51
+ const { chatId: from, senderId: sender, isGroup: isGroupRaw, rawParticipant } = canonicalKey(key, identity, message.participant);
52
+ const isGroup = from.endsWith('@g.us');
53
+ const isBroadcast = from === 'status@broadcast' || from.endsWith('@broadcast');
54
+ const kind = isGroup ? 'group' : isBroadcast ? 'broadcast' : from.endsWith('@newsletter') ? 'channel' : 'dm';
55
+ const rawIdentity = (isGroupRaw ? rawParticipant : key.remoteJid) ?? '';
56
+ const senderInfo = {
57
+ id: sender,
58
+ displayName: message.pushName ?? undefined,
59
+ platformData: {
60
+ phoneNumber: isPnUser(sender) ? sender : undefined,
61
+ lid: isLidUser(rawIdentity) ? rawIdentity : undefined,
62
+ },
63
+ };
64
+ const chat = { id: from, kind, isGroup, isBroadcast };
65
+ let body = '';
66
+ let type = 'unknown';
67
+ let location;
68
+ // Unwrap disappearing-mode, view-once and document-with-caption envelopes.
69
+ const m = normalizeMessageContent(message.message) ?? {};
70
+ if (m.conversation || m.extendedTextMessage?.text) {
71
+ body = (m.conversation ?? m.extendedTextMessage?.text ?? '').toString();
72
+ type = 'text';
73
+ }
74
+ else if (m.imageMessage) {
75
+ type = 'image';
76
+ body = m.imageMessage.caption ?? '';
77
+ }
78
+ else if (m.videoMessage) {
79
+ type = 'video';
80
+ body = m.videoMessage.caption ?? '';
81
+ }
82
+ else if (m.audioMessage) {
83
+ type = m.audioMessage.ptt ? 'voice' : 'audio';
84
+ }
85
+ else if (m.stickerMessage) {
86
+ type = 'sticker';
87
+ }
88
+ else if (m.documentMessage) {
89
+ type = 'document';
90
+ body = m.documentMessage.caption ?? '';
91
+ }
92
+ else if (m.locationMessage) {
93
+ type = 'location';
94
+ const l = m.locationMessage;
95
+ body = l.name ?? l.address ?? '';
96
+ location = toInboundLocation({ latitude: l.degreesLatitude, longitude: l.degreesLongitude, name: l.name, address: l.address });
97
+ }
98
+ else if (m.liveLocationMessage) {
99
+ type = 'location';
100
+ const l = m.liveLocationMessage;
101
+ body = l.caption ?? '';
102
+ location = toInboundLocation({ latitude: l.degreesLatitude, longitude: l.degreesLongitude, live: true });
103
+ }
104
+ else if (m.contactMessage || m.contactsArrayMessage) {
105
+ type = 'contact';
106
+ body = m.contactMessage?.displayName ?? m.contactsArrayMessage?.displayName ?? '';
107
+ }
108
+ else if (m.pollCreationMessage || m.pollCreationMessageV2 || m.pollCreationMessageV3) {
109
+ // V3 is the single-select poll, V2 the community-announcement one.
110
+ type = 'poll';
111
+ body = (m.pollCreationMessage ?? m.pollCreationMessageV2 ?? m.pollCreationMessageV3)?.name ?? '';
112
+ }
113
+ // An encrypted vote. BaileysProvider decrypts these into `poll-vote` events and
114
+ // never routes them here; kept for direct callers of this low-level mapper.
115
+ else if (m.pollUpdateMessage) {
116
+ type = 'poll';
117
+ }
118
+ else if (m.buttonsResponseMessage) {
119
+ type = 'buttons';
120
+ body = m.buttonsResponseMessage.selectedDisplayText ?? '';
121
+ }
122
+ else if (m.templateButtonReplyMessage) {
123
+ type = 'buttons';
124
+ body = m.templateButtonReplyMessage.selectedDisplayText ?? '';
125
+ }
126
+ else if (m.listResponseMessage) {
127
+ type = 'list';
128
+ body = m.listResponseMessage.title ?? '';
129
+ }
130
+ let media;
131
+ if (MEDIA_KINDS.has(type)) {
132
+ const part = m[`${type === 'voice' ? 'audio' : type}Message`];
133
+ media = { kind: type, mimetype: part?.mimetype ?? undefined, filename: part?.fileName ?? undefined, token: message };
134
+ }
135
+ const qc = contextInfoOf(m);
136
+ // Every user id below is canonical like `senderId`: a LID-addressed group
137
+ // quotes and mentions LIDs, so a known LID (the bot's own included) maps to
138
+ // its PN and matches selfId, and device suffixes are stripped.
139
+ const canonicalUser = (jid) => identity.canonical(normalize(jid) || jid);
140
+ const quotedAuthor = qc?.participant ?? undefined;
141
+ let quoted;
142
+ if (qc?.quotedMessage) {
143
+ quoted = {
144
+ body: textOf(qc.quotedMessage),
145
+ senderId: quotedAuthor ? canonicalUser(quotedAuthor) : undefined,
146
+ };
147
+ if (qc.stanzaId) {
148
+ // WAMessage-shaped, so `send(…, { quoted: quoted.ref })` threads the reply.
149
+ const native = {
150
+ key: { remoteJid: key.remoteJid, id: qc.stanzaId, fromMe: false, ...(quotedAuthor ? { participant: quotedAuthor } : {}) },
151
+ message: qc.quotedMessage,
152
+ };
153
+ quoted.ref = { id: qc.stanzaId, chatId: from, native };
154
+ }
155
+ }
156
+ const mentions = (qc?.mentionedJid ?? []).map(canonicalUser);
157
+ return {
158
+ ref: { id: key.id ?? '', chatId: from, native: message },
159
+ chat,
160
+ senderInfo,
161
+ from,
162
+ sender,
163
+ chatId: from,
164
+ senderId: sender,
165
+ isGroup,
166
+ isBroadcast,
167
+ fromMe: !!key.fromMe,
168
+ type,
169
+ body,
170
+ timestamp: timestampOf(message.messageTimestamp),
171
+ mentions,
172
+ quoted,
173
+ media,
174
+ ...(location ? { location } : {}),
175
+ raw: message,
176
+ };
177
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * toOutbound — map a transport-neutral {@link OutboundContent} to a Baileys
3
+ * `AnyMessageContent`. WhatsApp has no reliable native buttons/lists, so those
4
+ * degrade to numbered text; Cloud-API-only kinds (`template`) and `native`
5
+ * throw {@link UnsupportedContentError}.
6
+ */
7
+ import type { AnyMessageContent } from '@whiskeysockets/baileys';
8
+ import { type OutboundContent } from '@bot-wa/core';
9
+ import { type RemoteMediaConfig } from './MediaProcessor.js';
10
+ export declare function toOutbound(content: OutboundContent, remoteMedia?: RemoteMediaConfig): Promise<AnyMessageContent>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * toOutbound — map a transport-neutral {@link OutboundContent} to a Baileys
3
+ * `AnyMessageContent`. WhatsApp has no reliable native buttons/lists, so those
4
+ * degrade to numbered text; Cloud-API-only kinds (`template`) and `native`
5
+ * throw {@link UnsupportedContentError}.
6
+ */
7
+ import { UnsupportedContentError } from '@bot-wa/core';
8
+ import { toMediaUpload, resolveRemoteMedia, makeSticker, transcodeToOpus } from './MediaProcessor.js';
9
+ function vcard(phone, displayName) {
10
+ const digits = phone.replace(/[^\d]/g, '');
11
+ return ('BEGIN:VCARD\nVERSION:3.0\n' +
12
+ `FN:${displayName ?? phone}\n` +
13
+ `TEL;type=CELL;type=VOICE;waid=${digits}:${phone}\n` +
14
+ 'END:VCARD');
15
+ }
16
+ function numberedButtons(text, rows, footer) {
17
+ const menu = rows.map((r, i) => `${i + 1}) ${r.title}`).join('\n');
18
+ return [text, '', menu, footer ? `\n${footer}` : ''].filter(Boolean).join('\n');
19
+ }
20
+ export async function toOutbound(content, remoteMedia = {}) {
21
+ const upload = (media) => resolveRemoteMedia(toMediaUpload(media), content.kind, remoteMedia);
22
+ switch (content.kind) {
23
+ case 'text':
24
+ return { text: content.text };
25
+ case 'image':
26
+ return { image: await upload(content.media), caption: content.caption, viewOnce: content.viewOnce };
27
+ case 'video':
28
+ return { video: await upload(content.media), caption: content.caption, gifPlayback: content.gifPlayback, ptv: content.ptv, viewOnce: content.viewOnce };
29
+ case 'audio':
30
+ return { audio: await upload(content.media), mimetype: 'audio/ogg; codecs=opus', ptt: false };
31
+ case 'voice': {
32
+ const opus = await transcodeToOpus(content.media);
33
+ return { audio: opus, mimetype: 'audio/ogg; codecs=opus', ptt: true };
34
+ }
35
+ case 'document':
36
+ return { document: await upload(content.media), fileName: content.filename, caption: content.caption, mimetype: content.mimetype };
37
+ case 'sticker': {
38
+ const sticker = await makeSticker(content.media, { pack: content.pack, author: content.author });
39
+ return { sticker };
40
+ }
41
+ case 'location':
42
+ return { location: { degreesLatitude: content.lat, degreesLongitude: content.lng, name: content.name, address: content.address } };
43
+ case 'contact':
44
+ return { contacts: { displayName: content.displayName ?? content.phone, contacts: [{ vcard: vcard(content.phone, content.displayName) }] } };
45
+ case 'form':
46
+ throw new UnsupportedContentError('form', 'This transport has no native forms — flow().form() asks field by field instead (capabilities.forms is not "native").');
47
+ case 'poll':
48
+ return { poll: { name: content.question, values: content.options, selectableCount: content.multiSelect ? content.options.length : 1 } };
49
+ case 'reaction':
50
+ return { react: { text: content.emoji, key: content.target.native.key } };
51
+ case 'buttons':
52
+ return { text: numberedButtons(content.text, content.buttons.map((b) => ({ title: b.title })), content.footer) };
53
+ case 'list':
54
+ return { text: numberedButtons(content.text, content.sections.flatMap((s) => s.rows), content.footer) };
55
+ case 'card':
56
+ return { text: [content.title, content.body].filter(Boolean).join('\n') };
57
+ case 'template':
58
+ throw new UnsupportedContentError('template', 'WhatsApp templates require the Cloud API transport, not Baileys.');
59
+ case 'native':
60
+ throw new UnsupportedContentError('native', 'Baileys has no native-component surface.');
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@bot-wa/baileys",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0-alpha.0",
4
+ "type": "module",
5
+ "description": "WhatsApp transport for @bot-wa/core, built on Baileys 7 (IProvider v2).",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ }
13
+ },
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "keywords": [
18
+ "bot",
19
+ "baileys",
20
+ "whatsapp",
21
+ "provider",
22
+ "transport",
23
+ "bot-wa"
24
+ ],
25
+ "author": "Andres Aya",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/andresayac/bot-wa-baileys.git",
30
+ "directory": "packages/baileys-provider"
31
+ },
32
+ "homepage": "https://github.com/andresayac/bot-wa-baileys/tree/main/packages/baileys-provider#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/andresayac/bot-wa-baileys/issues"
35
+ },
36
+ "files": [
37
+ "./lib/**/*"
38
+ ],
39
+ "dependencies": {
40
+ "@cacheable/node-cache": "^1.5.0",
41
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
42
+ "@hapi/boom": "^10.0.1",
43
+ "@whiskeysockets/baileys": "7.0.0-rc14",
44
+ "fluent-ffmpeg": "^2.1.3",
45
+ "follow-redirects": "^1.16.0",
46
+ "mime-types": "^3.0.2",
47
+ "pino": "^10.3.1",
48
+ "qr-image": "^3.2.0",
49
+ "qrcode-terminal": "^0.12.0",
50
+ "sharp": "^0.35.3",
51
+ "@bot-wa/core": "^2.0.0-alpha.2",
52
+ "@bot-wa/http": "^0.2.0-alpha.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/fluent-ffmpeg": "^2.1.28",
56
+ "@types/mime-types": "^3.0.1",
57
+ "@types/node": "^26.1.1",
58
+ "@types/qrcode-terminal": "^0.12.2",
59
+ "typescript": "^7.0.2",
60
+ "vite": "^7.3.6",
61
+ "vitest": "^4.1.10"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc",
68
+ "test": "vitest run",
69
+ "start": "pnpm run build && tsc -p examples/tsconfig.json && node examples/bot.js",
70
+ "example": "pnpm run start",
71
+ "typecheck": "tsc -p tsconfig.test.json"
72
+ }
6
73
  }