merge lane-lic: Baileys and libsignal opt in, true GeoIP licences

Brings in S3 (lane-lic, tip a39316c2): the WhatsApp client (Baileys, which
depends on libsignal, GPL-3.0) is no longer in the default npm install; it
lives under optional/whatsapp with its own lock file and is installed by
npm run whatsapp:install. A test asserts that a default install holds no
GPL package; the GeoIP data is described with its true licences.

Conflicts and resolutions:
- .github/test-floors.json: kept the final review's playwright floor and
  its description, added this lane's whatsapp floor and its description,
  and the counts of the previous merge. Measured values replace them in
  the floors commit that follows.
- .github/scripts/test-guard.mjs, tests/repo/test-guard.test.ts: both
  sides added a suite name to the usage text, the comment and the list
  CI must run; the result accepts whatsapp and playwright both.
- package-lock.json: regenerated from the final review's lock file with
  npm install --package-lock-only after packages/channels/package.json
  dropped baileys, so the lock holds neither baileys nor libsignal (the
  opt-in client has its own lock under optional/whatsapp).
- packages/channels/src/whatsapp-baileys/baileys.ts: the final review
  added textContent (linkPreview: null, so Baileys fetches no link of a
  text from the control plane) and exported BaileysConnection; this lane
  moved the library behind client.ts and added socketConfig. Both stay;
  WhatsAppSocket.sendMessage in client.ts now types linkPreview, so the
  typecheck made with the client installed holds textContent against the
  adapter's view of the socket.
- packages/channels/test/baileys.test.ts: the final review's two tests of
  what is sent. The one that runs Baileys' own generateWAMessageContent
  needs the library, so it moved to packages/channels/test-optin/
  sending.test.ts (npm run test:whatsapp); the one with a fake socket
  needs no library and stays in the default suite.

No test removed. Verified on this merge: typecheck, vitest 2272 passed,
typecheck:whatsapp, test:whatsapp 16 passed on a Windows host.
This commit is contained in:
feder-cr committed 2026-10-06 20:04:28 +02:00
commit dab32a4a47
43 files changed
+2637 -909

No files matched your search

+3 -3
View File
@@ -14,8 +14,8 @@
// JUnit XML report (a file ending in .xml: pytest --junitxml for
// the Python engine, Playwright's junit reporter for the web
// client's browser tests).
// --suite the entry of the floors file to apply: vitest, postgres, go,
// pytest or playwright.
// --suite the entry of the floors file to apply: vitest, postgres, whatsapp,
// go, pytest or playwright.
// --floors default .github/test-floors.json. The entry is chosen by the
// host this runs on (linux, win32), so CI and the pre-push hook
// read the same numbers from the same file.
@@ -35,7 +35,7 @@ function fail(message) {
const args = process.argv.slice(2);
const reportPath = args.shift();
const usage = "usage: test-guard.mjs <report> --suite <vitest|postgres|go|pytest|playwright> [--floors <file>]";
const usage = "usage: test-guard.mjs <report> --suite <vitest|postgres|whatsapp|go|pytest|playwright> [--floors <file>]";
if (!reportPath) fail(usage);
let suite;
let floorsPath = ".github/test-floors.json";
+5 -1
View File
@@ -1,5 +1,5 @@
{
"//": "What each suite must reach on each host, read by .github/scripts/test-guard.mjs in CI (tests.yml) and in the pre-push hook. Raise a floor when tests are added; lowering one is a decision to write down in the commit that does it. Linux skips only the tests that drive a real Windows PowerShell; a Windows host compiles dot-agentd's unix-only test files out and skips the two Go tests that need a Linux guest. The playwright floor is the web client's browser tests (the web job of tests.yml; the same hundred and one run on a Windows host, which is where they are developed). The pytest floor is the Python engine's (invisible_engine_dots, the engine job of tests.yml, Linux only, nothing may skip).",
"//": "What each suite must reach on each host, read by .github/scripts/test-guard.mjs in CI (tests.yml) and in the pre-push hook. Raise a floor when tests are added; lowering one is a decision to write down in the commit that does it. Linux skips only the tests that drive a real Windows PowerShell; a Windows host compiles dot-agentd's unix-only test files out and skips the two Go tests that need a Linux guest. The playwright floor is the web client's browser tests (the web job of tests.yml; the same hundred and one run on a Windows host, which is where they are developed). The pytest floor is the Python engine's (invisible_engine_dots, the engine job of tests.yml, Linux only, nothing may skip). The whatsapp floor is the suite that needs the real WhatsApp client, an opt-in install (npm run test:whatsapp, the whatsapp-client job of tests.yml, which installs the client and runs it with PostgreSQL, so the pg runs of the auth state count); a host without PostgreSQL reaches the win32 number.",
"vitest": {
"linux": { "min_passed": 2258, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
"win32": { "min_passed": 2261, "allow_skipped": "" }
@@ -7,6 +7,10 @@
"postgres": {
"linux": { "min_passed": 711, "allow_skipped": "", "require": { "\\bpg\\b": 281 } }
},
"whatsapp": {
"linux": { "min_passed": 24, "allow_skipped": "", "require": { "\\bpg\\b": 9 } },
"win32": { "min_passed": 15, "allow_skipped": "" }
},
"go": {
"linux": { "min_passed": 120, "allow_skipped": "" },
"win32": { "min_passed": 70, "allow_skipped": "^agentd (TestSystemRoute|TestPowerOffStartsTheCommandAndAnswers202)$" }
+40 -2
View File
@@ -100,6 +100,44 @@ jobs:
if: always()
run: node .github/scripts/test-guard.mjs vitest-report.json --suite postgres
# The WhatsApp client (Baileys) is an opt-in install: it depends on libsignal, which is GPL-3.0, and nothing GPL is
# installed by default. The jobs above never install it. This one does, with the command a person runs
# (npm run whatsapp:install), to prove the adapter against the real library: the types (the real module has the
# shape the adapter writes down) and the tests that need it, on PGlite and on PostgreSQL.
whatsapp-client:
name: the WhatsApp client, opt-in (types and tests on the real library)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18.4-alpine3.23
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 2s
--health-timeout 5s
--health-retries 30
env:
DATABASE_URL: postgres://postgres:[email protected]:5432/postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci --no-audit --no-fund
- name: install the client, as a person who enables WhatsApp does
run: npm run whatsapp:install
- name: the real module has the shape the adapter writes down
run: npm run typecheck:whatsapp
- name: vitest with the real client
run: npm run test:whatsapp -- --reporter=default --reporter=json --outputFile=vitest-report.json
- name: refuse a run with too few tests or an unexpected skip
if: always()
run: node .github/scripts/test-guard.mjs vitest-report.json --suite whatsapp
iso:
name: the Linux kernel reads our ISO images
runs-on: ubuntu-latest
@@ -271,7 +309,7 @@ jobs:
python -m invisible_core.claims
gate:
needs: [node, postgres, iso, go, engine, smoke, browser-smoke, web, english, claims]
needs: [node, postgres, whatsapp-client, iso, go, engine, smoke, browser-smoke, web, english, claims]
if: always()
runs-on: ubuntu-latest
steps:
@@ -283,7 +321,7 @@ jobs:
node -e '
const needs = JSON.parse(process.env.RESULTS);
const names = Object.keys(needs);
if (names.length !== 10) { console.error(`expected 10 jobs, got ${names.length}`); process.exit(1); }
if (names.length !== 11) { console.error(`expected 11 jobs, got ${names.length}`); process.exit(1); }
let ok = true;
for (const name of names) {
console.log(`${name}: ${needs[name].result}`);
+24 -9
View File
@@ -492,7 +492,13 @@ curl -X PATCH http://127.0.0.1:8787/api/dots/my-first-dot/channels/telegram \
> which can get the account restricted or banned. Use a number of its own, such
> as a spare SIM or eSIM, never the one you live on.
Start the server with `INVISIBLE_DOTS_WHATSAPP=1`, then:
The WhatsApp client is not part of the default install (see below). Install it
once, from the repository folder, then start the server with
`INVISIBLE_DOTS_WHATSAPP=1`:
```sh
npm run whatsapp:install
```
```sh
invisible-dots channel link whatsapp --dot my-first-dot
@@ -506,10 +512,13 @@ never writes first, to anyone, answers only people who paired, in private
chats, and takes text only. Approvals are answered in words
(`yes ap-xxxxxx` or `no ap-xxxxxx`); a bare `yes` is an ordinary message.
Baileys depends on `libsignal`, which is GPL-3.0. `npm ci` installs both into
`node_modules` for everyone; neither is in this repository or in the bundled
command, and the server loads them only when WhatsApp is linked
([THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)).
Baileys depends on `libsignal`, which is GPL-3.0, so nothing of it is installed
by default: `npm ci` installs neither, and neither is in the bundled command.
`npm run whatsapp:install` installs them into `optional/whatsapp/`, pinned to
one release by a lock file with the integrity of every package, and the server
loads them from there only when WhatsApp is linked. Without them, or with a
release other than the pinned one (after a `git pull` that moves the pin, run the
command again), linking says how to enable it ([THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)).
The details of both:
[architecture: messaging channels](docs/architecture.md#98-messaging-channels).
@@ -610,7 +619,7 @@ so it is kept private to your user.
| `INVISIBLE_DOTS_WEB_LISTEN` | `127.0.0.1:3000` | where the web client listens |
| `INVISIBLE_DOTS_WEB_ALLOWED_HOSTS` | loopback only | other host names the web client answers to |
| `INVISIBLE_DOTS_QEMU_DIR` | official installer's folder, then `PATH` | the one folder QEMU is looked for in |
| `INVISIBLE_DOTS_WHATSAPP` | off | `1` offers the WhatsApp channel |
| `INVISIBLE_DOTS_WHATSAPP` | off | `1` offers the WhatsApp channel (after `npm run whatsapp:install`) |
| `DATABASE_URL` | embedded PGlite | an external PostgreSQL 16 or newer |
| `INVISIBLE_DOTS_DEBUG` | off | `1` adds debug lines to the server's log |
@@ -786,9 +795,15 @@ parts of this repository's history come from Open Multi-Agent, also MIT. Their
notices are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). QEMU (GPL-2.0)
is installed from its official installer or your distribution and only run as
a separate program, never bundled. The guest operating system, the browser
engine and the packages a host downloads keep their own licenses; whoever
copies a golden image to another machine takes on theirs
([architecture: licensing](docs/architecture.md#113-licensing)).
engine and the packages a host downloads keep their own licenses, and so does
the GeoIP data in the golden image: it is built on your machine from the
sources' free databases (CC BY-SA 4.0, the GeoLite2 EULA, CC BY 4.0 and others,
listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)), and invisible_dots
does not redistribute it. Whoever copies a golden image to another machine takes
on those licenses
([architecture: licensing](docs/architecture.md#113-licensing)). Nothing under
the GPL is installed by default; the WhatsApp client, which brings a GPL-3.0
dependency, is an opt-in install.
invisible_dots is an independent project, not affiliated with OpenRouter,
Telegram, WhatsApp or Meta.
+68 -24
View File
@@ -12,7 +12,10 @@ TypeScript engine, and the web client's files that derive from other projects
has a section below) come with the notices below. The golden image that
`invisible-dots image build` makes on a host also holds data that is not part
of this repository, the GeoIP database: its credits are in the section "GeoIP
data in the golden image".
data in the golden image". Nothing under the GPL is installed by default; the
one GPL-3.0 dependency, `libsignal` of the optional WhatsApp client, is left out
of the default install and is described in the section "The opt-in WhatsApp
client and `libsignal` (GPL-3.0)".
## Open Multi-Agent
@@ -139,18 +142,32 @@ launch downloads nothing and asks for no newer release: the file `geoip-aio-all.
pinned in `guest/image-builder/pins.json` by its URL and SHA-256 and recorded
in each golden manifest as `pinned.geoip`. That project merges the free
editions of the databases below into one file (country codes, coordinates and
a time zone computed from them), and each of them is published on the
condition that the product using it credits it. The image is built on the host
of whoever runs Dots and this repository does not distribute it or the data;
the credits are kept here, and in each golden manifest as `notices`
(`guest/image-builder/src/geoip-notices.ts` holds the one list, and a test
keeps this section equal to it). Each source's license governs its own data;
the merged file is published by `daijro/geoip-all-in-one` under the GPL-3.0, the
first entry below (its repository declares that license, and the pinned release
is the file it publishes).
a time zone computed from them).
The golden image is built by the person who runs Dots, on their own machine
(`invisible-dots image build` downloads the pinned file to that machine), and
invisible_dots publishes no image and no copy of the data: it does not
redistribute them. What the person holds is governed by the licenses of the
data sources, which are the ones listed below. The merged file has no license
of its own to name: its project declares the GPL-3.0 on its repository, which
is the license of its scripts, and its README states no license for the data it
publishes, only the credits three of its sources ask for. So each source's own
license applies to what it contributed, and the image must not be taken for
GPL-licensed data because GitHub shows a GPL on that repository. Whoever
shares an image or the file with others has to meet the terms below, among them
the share-alike of CC BY-SA 4.0 and the GeoLite2 End User License Agreement.
The credits, source by source, are kept here and in each golden manifest as
`notices`, with the statement above as `notices_statement`
(`guest/image-builder/src/geoip-notices.ts` holds the one list and the one
statement, and a test keeps this section equal to them).
Statement, as the manifest records it:
The GeoIP database in the golden image is the file geoip-aio-all.mmdb of one pinned release of daijro/geoip-all-in-one. It is downloaded and built into the image by the person who runs the build, on their own machine; invisible_dots does not publish or redistribute the image or the data. The file merges the databases listed here, and its project states no license for the merged file (the GPL-3.0 in its repository is the license of its scripts), so each source's own license applies to what it contributed: CC BY-SA 4.0 for IP2Location LITE, IPinfo and IPLocate.io (adaptations must be shared under the same license, with credit), the MaxMind GeoLite2 End User License Agreement, CC BY 4.0 for DB-IP Lite, CC0 1.0, and the ODbL 1.0 for the OpenStreetMap-derived time zone boundaries. Whoever shares the image or the file has to meet all of those terms.
Sources, each under its own data license:
- daijro/geoip-all-in-one (https://github.com/daijro/geoip-all-in-one), which merges the databases below and publishes the file the image carries, GPL-3.0
(https://www.gnu.org/licenses/gpl-3.0.html): The file geoip-aio-all.mmdb is published by daijro/geoip-all-in-one (https://github.com/daijro/geoip-all-in-one) under the GPL-3.0.
- IP2Location LITE (https://lite.ip2location.com), CC BY-SA 4.0
(https://creativecommons.org/licenses/by-sa/4.0/): This site or product includes IP2Location LITE data available from https://lite.ip2location.com.
- MaxMind GeoLite2 (https://www.maxmind.com), GeoLite2 End User License Agreement
@@ -161,7 +178,7 @@ is the file it publishes).
(https://creativecommons.org/licenses/by-sa/4.0/): IP address data powered by IPinfo (https://ipinfo.io)
- IPLocate.io free IP to Country database (https://www.iplocate.io), CC BY-SA 4.0
(https://creativecommons.org/licenses/by-sa/4.0/): IP address data powered by IPLocate.io (https://www.iplocate.io)
- GeoFeed + Whois + ASN country database of tdulcet/ip-geolocation-dbs, built from sapics/ip-location-db, CC0 1.0
- GeoFeed + Whois + ASN country database of tdulcet/ip-geolocation-dbs, built from sapics/ip-location-db, which lists the same data under PDDL 1.0, CC0 1.0
(https://creativecommons.org/publicdomain/zero/1.0/): No credit is required; the data comes from tdulcet/ip-geolocation-dbs (https://github.com/tdulcet/ip-geolocation-dbs).
- OpenStreetMap contributors, through the time zone boundaries of timezone-boundary-builder that tzfpy carries, ODbL 1.0
(https://opendatacommons.org/licenses/odbl/1-0/): Contains time zone data derived from OpenStreetMap, (c) OpenStreetMap contributors (https://www.openstreetmap.org/copyright).
@@ -528,14 +545,41 @@ SOFTWARE.
## The opt-in WhatsApp client and `libsignal` (GPL-3.0)
The WhatsApp adapter (`packages/channels/src/whatsapp-baileys/`) loads Baileys
(MIT) from `node_modules` when a person links WhatsApp. Baileys depends on the
npm package `libsignal`, which is under the GNU General Public License,
version 3, and so does not fit this repository's MIT license. None of it is
part of this repository: it is installed by `npm ci` like any dependency, the
command bundle that `apps/cli/scripts/build.mjs` makes leaves Baileys (and with
it `libsignal`) out, and nothing loads it unless the server was started with
`INVISIBLE_DOTS_WHATSAPP=1` and a person links a number. Whoever distributes a
build of this repository together with its `node_modules`, or wants a GPL-free
install, has to take that into account: the way out is to leave the adapter
and its dependency out (`packages/channels/package.json`).
Nothing under the GPL is installed by default. The WhatsApp adapter
(`packages/channels/src/whatsapp-baileys/`) uses Baileys (MIT), a client of the
WhatsApp Web protocol, which depends on the npm package `libsignal`, under the
GNU General Public License, version 3, and so does not fit this repository's MIT
license. They are therefore not part of the default install:
- No workspace declares Baileys, as a dependency of any kind, and the root
`package-lock.json` holds none of it or of what only it needs, so `npm ci`
installs no GPL package (`tests/repo/default-install-licenses.test.ts` scans
the lock file for licenses).
- The client lives apart, in `optional/whatsapp/`, with a `package.json` that
pins Baileys to one exact release and a `package-lock.json` with the
integrity hash of every package. A person who turns WhatsApp on installs it
with one command from the repository root: `npm run whatsapp:install` (a
clean install of that lock file, without install scripts). Installing it is
that person's choice, and what they then hold, Baileys and `libsignal`,
is under those licenses.
- The adapter loads the client from that folder, by path, only when a
connection opens and only if it is there. Otherwise it says how to enable it:
run `npm run whatsapp:install`, then start the server with
`INVISIBLE_DOTS_WHATSAPP=1`.
- The command bundle that `apps/cli/scripts/build.mjs` makes does not hold it
(the bundle has no import of it), so no build of this repository is a GPL
work. Whoever distributes a build together with an installed
`optional/whatsapp/node_modules`, which is GPL-3.0 code, has to take that into
account.
## LGPL image libraries that Next.js may install
The web client's framework, Next.js, lists `sharp` (an image library,
Apache-2.0) as an optional dependency, and `sharp` lists prebuilt binaries for
each platform, `@img/sharp-*` and `@img/sharp-libvips-*`, which carry libvips
under the LGPL-3.0 (`LGPL-3.0-or-later`, or `Apache-2.0 AND LGPL-3.0-or-later`
for the Windows builds). `npm ci` installs the ones that fit the host, as
optional packages. They are LGPL, not GPL, and unmodified; whoever distributes
an installation together with its `node_modules` has to meet the LGPL for them.
`tests/repo/default-install-licenses.test.ts` allows no LGPL package in the lock
file other than these.
+27 -7
View File
@@ -5,7 +5,15 @@
* with `untilStopSignal`; tests call `startServer` and close the handle
* themselves.
*/
import { ChannelHub, TelegramChannelType, WhatsAppChannelType, type ChannelType } from "@invisible-dots/channels";
import { fileURLToPath } from "node:url";
import {
ChannelHub,
TelegramChannelType,
WhatsAppChannelType,
whatsappClientDir,
whatsappClientProblem,
type ChannelType,
} from "@invisible-dots/channels";
import type { Database } from "@invisible-dots/database";
import type { HostFacts } from "@invisible-dots/sdk/types";
import { errorMessage, prefixedStderrLogger, Scheduler, type ComputerDriver, type Logger, type SchedulerOptions } from "@invisible-dots/scheduler";
@@ -32,6 +40,8 @@ export interface StartServerOptions {
scheduler?: Omit<SchedulerOptions, "db" | "driver" | "logger">;
/** The kinds of messaging channel this server can run; default Telegram, and WhatsApp when INVISIBLE_DOTS_WHATSAPP=1. Tests pass fakes. */
channelTypes?: readonly ChannelType[];
/** Where the opt-in WhatsApp client is installed; default `optional/whatsapp` of the repository. Tests point it at a folder of their own. */
whatsappClientDir?: string;
}
/** The logger of the `invisible-dots` process: lines on stderr prefixed with its name, debug lines when INVISIBLE_DOTS_DEBUG=1. */
@@ -59,12 +69,19 @@ export interface RunningServer {
}
/**
* The channels a server runs when it is not told otherwise. WhatsApp is opt-in: its adapter is an unofficial
* client that WhatsApp can answer with a ban of the linked account, so a server that was not asked for it neither
* loads nor offers it.
* Where the opt-in WhatsApp client is installed, under the repository root. apps/api/src/start.ts and the command
* bundle apps/cli/dist/invisible-dots.mjs sit at the same depth, so the root is the same for both.
*/
export function defaultChannelTypes(env: Record<string, string | undefined>): ChannelType[] {
return [new TelegramChannelType(), ...(env[ENV.WHATSAPP] === "1" ? [new WhatsAppChannelType()] : [])];
const DEFAULT_CLIENT_DIR = whatsappClientDir(fileURLToPath(new URL("../../../", import.meta.url)));
/**
* The channels a server runs when it is not told otherwise. WhatsApp is opt-in twice: its adapter is an unofficial
* client that WhatsApp can answer with a ban of the linked account, so a server that was not asked for it neither
* loads nor offers it; and its client library is not part of the default install (it is GPL-3.0 through libsignal),
* so it is installed by the person who turns WhatsApp on.
*/
export function defaultChannelTypes(env: Record<string, string | undefined>, clientDir: string = DEFAULT_CLIENT_DIR): ChannelType[] {
return [new TelegramChannelType(), ...(env[ENV.WHATSAPP] === "1" ? [new WhatsAppChannelType({ baileys: { clientDir } })] : [])];
}
/**
@@ -111,11 +128,14 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
logger: options.logger ?? prefixedStderrLogger("scheduler", debug),
});
cleanup.push(() => scheduler.close());
const clientDir = options.whatsappClientDir ?? DEFAULT_CLIENT_DIR;
const clientProblem = !options.channelTypes && env[ENV.WHATSAPP] === "1" ? whatsappClientProblem(clientDir) : null;
if (clientProblem) logger.warn(`${ENV.WHATSAPP}=1 is set. ${clientProblem.message}`);
// Next to the Scheduler, using only what it offers; registered after it, so it stops first.
const channels = new ChannelHub({
db,
host: scheduler,
types: options.channelTypes ?? defaultChannelTypes(env),
types: options.channelTypes ?? defaultChannelTypes(env, clientDir),
logger: options.logger ?? prefixedStderrLogger("channels", debug),
});
cleanup.push(() => channels.close());
+43 -1
View File
@@ -5,7 +5,7 @@
*/
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs";
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { FakeChannelType } from "@invisible-dots/channels/testing";
@@ -254,4 +254,46 @@ describe("startServer on an empty INVISIBLE_DOTS_HOME", { timeout: 120_000 }, ()
const server = await start();
expect(server.database).toBe("pglite");
});
it("with INVISIBLE_DOTS_WHATSAPP=1 and no WhatsApp client installed, starts anyway and says in the log how to install it", async () => {
const warnings: string[] = [];
const logger = { ...quiet, warn: (message: string) => void warnings.push(message) };
const server = await start({ env: envFor(home, { INVISIBLE_DOTS_WHATSAPP: "1" }), logger, whatsappClientDir: join(home, "no-client") });
expect(warnings.filter((m) => m.includes("WhatsApp client is not installed"))).toHaveLength(1);
expect(warnings.join("\n")).toContain("npm run whatsapp:install");
expect(server.channels.kinds).toEqual(["telegram", "whatsapp"]);
const api = new InvisibleDotsClient({ baseUrl: server.url, token: server.token });
expect(await api.health()).toMatchObject({ status: "ok" });
});
it("with INVISIBLE_DOTS_WHATSAPP=1 and a client that is not the pinned release, starts anyway and says in the log which releases differ", async () => {
const client = join(home, "client");
await mkdir(join(client, "node_modules", "baileys"), { recursive: true });
await writeFile(join(client, "package.json"), JSON.stringify({ dependencies: { baileys: "1.2.4" } }));
await writeFile(join(client, "node_modules", "baileys", "package.json"), JSON.stringify({ name: "baileys", version: "1.2.3", main: "index.js" }));
await writeFile(join(client, "node_modules", "baileys", "index.js"), "");
const warnings: string[] = [];
const logger = { ...quiet, warn: (message: string) => void warnings.push(message) };
const server = await start({ env: envFor(home, { INVISIBLE_DOTS_WHATSAPP: "1" }), logger, whatsappClientDir: client });
const line = warnings.filter((m) => m.includes("WhatsApp"));
expect(line).toHaveLength(1);
expect(line[0]).toContain("1.2.3");
expect(line[0]).toContain("1.2.4");
expect(line[0]).toContain("npm run whatsapp:install");
expect(server.channels.kinds).toEqual(["telegram", "whatsapp"]);
});
it("says nothing of the WhatsApp client when WhatsApp is not asked for, or when the client is there", async () => {
const client = join(home, "client");
await mkdir(join(client, "node_modules", "baileys"), { recursive: true });
await writeFile(join(client, "package.json"), JSON.stringify({ dependencies: { baileys: "1.2.3" } }));
await writeFile(join(client, "node_modules", "baileys", "package.json"), JSON.stringify({ name: "baileys", version: "1.2.3", main: "index.js" }));
await writeFile(join(client, "node_modules", "baileys", "index.js"), "");
const warnings: string[] = [];
const logger = { ...quiet, warn: (message: string) => void warnings.push(message) };
const first = await start({ logger, whatsappClientDir: join(home, "no-client") });
await stopped(first);
await start({ env: envFor(home, { INVISIBLE_DOTS_WHATSAPP: "1" }), logger, whatsappClientDir: client });
expect(warnings.filter((m) => m.includes("WhatsApp"))).toEqual([]);
});
});
+4 -3
View File
@@ -23,9 +23,10 @@ await build({
target: "node24",
sourcemap: true,
// pg loads its optional native binding with require(); it is never installed here.
// baileys (the opt-in WhatsApp client) stays out of the bundle: it depends on libsignal, which is GPL-3.0, and a bundle
// that held it would be a GPL work. It is loaded from node_modules when WhatsApp is linked, and only then.
external: ["pg-native", "baileys"],
// The opt-in WhatsApp client (Baileys, which depends on libsignal, GPL-3.0) is not in the bundle either: the adapter
// loads it at run time from optional/whatsapp/ by path (packages/channels/src/whatsapp-baileys/client.ts), so no
// import of it is in the bundle's graph, and a bundle that held it would be a GPL work.
external: ["pg-native"],
banner: {
// CommonJS dependencies inside an ESM bundle still call require().
js: "#!/usr/bin/env node\nimport { createRequire as __createRequire } from 'node:module';\nconst require = __createRequire(import.meta.url);",
+62 -11
View File
@@ -293,8 +293,10 @@ QEMU on the same disk would corrupt it.
most recently before it (older ones are removed after a build), so a tree
whose pinned release the project has since deleted still builds from a host
that has it, and the cache does not grow with every weekly pin;
the data's credits are in the manifest's `notices` and in
`THIRD_PARTY_NOTICES.md`), and the whole Python environment of
the data's licenses and credits, source by source, are in the manifest's
`notices` and `notices_statement` and in `THIRD_PARTY_NOTICES.md`: they are the
sources' own data licenses, and since the person who runs the build makes the
image on their own machine, this project does not redistribute the data), and the whole Python environment of
`invisible-playwright-mcp`, transitive packages included, by
`guest/image-builder/builder/mcp-requirements.lock`, every package at an
exact version with the SHA-256 of its files. The builder installs it with
@@ -2507,19 +2509,50 @@ of its own (a spare SIM or eSIM), never the one a person lives on. The official
Cloud API (a business account and a public webhook) is a later adapter on the same
hub.
- **Licenses.** Baileys is MIT, but it depends on `libsignal`, which is GPL-3.0.
Neither is in this repository; the command bundle leaves Baileys out
(`external` in `apps/cli/scripts/build.mjs`, checked by a test), so no build of
ours embeds GPL code, and the server resolves it from `node_modules` when WhatsApp
is linked. `THIRD_PARTY_NOTICES.md` says what that means for whoever
distributes an installation.
- **Licenses and the opt-in install.** Baileys is MIT, but it depends on
`libsignal`, which is GPL-3.0, and nothing GPL is installed by default. No
workspace declares Baileys (not as a regular, dev, optional or peer dependency),
so the root `package-lock.json` holds none of it and `npm ci` installs no GPL
package (`tests/repo/default-install-licenses.test.ts` scans the lock file's
licenses). The client lives apart in `optional/whatsapp/`: its own
`package.json` (Baileys at one exact version, a release candidate) and
`package-lock.json` (the integrity hash of every package), outside the
workspaces. One command installs it, `npm run whatsapp:install` from the
repository root: `npm ci --prefix optional/whatsapp --ignore-scripts`, a clean
install of that lock file without install scripts. `client.ts` is the one file
that names the library: it looks for it in that folder when a connection opens
(`createRequire` from the folder, then one `import()` of the file's URL), loads it
only if it is there and is the pinned release, and otherwise throws
`WhatsAppClientMissingError` (not installed) or `WhatsAppClientVersionError` (the
installed `node_modules/baileys` is not the exact version
`optional/whatsapp/package.json` declares, which stays the one owner of the pin:
a pull that moves it leaves the old release installed until the install is made
again). Both messages name the command and the variable that turn WhatsApp on.
The error is the channel's status detail, the server logs it at start when
`INVISIBLE_DOTS_WHATSAPP=1` is set and `whatsappClientProblem` finds one (the
same check the loader makes), and the "it is off" answer of linking carries the
same words (`WHATSAPP_ENABLE_HELP`). Nothing imports the
library by name, not even as types: the adapter writes down what it uses of it
(`WhatsAppClient`, with the library's credentials and key data as type
parameters) and compiles without it; `npm run typecheck:whatsapp` (a CI job,
with the client installed) proves the real module has that shape, that the
options of the socket the adapter builds (`socketConfig`, typed
`WhatsAppSocketConfig`) are each an option of the library with a type it takes
and none is a name it lacks, that the adapter's auth state is one the library
accepts, and that the key groups are the library's own. (The methods of
`WhatsAppClient` are compared in both directions, so those two are asserted
separately, one way.) `npm run test:whatsapp` runs the tests that need
the real library (`packages/channels/test-optin/`, left out of the default
suite). The command bundle has no import of it, so no build of ours embeds GPL
code. `THIRD_PARTY_NOTICES.md` says what that means for whoever distributes an
installation that has `optional/whatsapp/node_modules`.
- **Off by default.** The server runs WhatsApp only when started with
`INVISIBLE_DOTS_WHATSAPP=1` (`defaultChannelTypes` in `apps/api/src/start.ts`).
Otherwise the type does not exist: `GET .../channels` lists only the kinds it
runs (`available`), and linking answers 400 with how to turn it on. Baileys is
loaded by a dynamic `import()` when a connection opens, so a server that never
links WhatsApp never loads it, and no file but `baileys.ts` and `auth-state.ts`
names it (a test reads the sources).
loaded by `client.ts` when a connection opens, so a server that never
links WhatsApp never loads it, and no file but `client.ts` names it (a test reads
the sources).
- **One port.** `port.ts` is what the channel needs of a connection (messages in,
text out, a code to scan, why it ended); `baileys.ts` implements it over
the network and `FakeWhatsAppConnector` for tests, because WhatsApp cannot be
@@ -2737,3 +2770,21 @@ downloaded from their publishers when a host builds its golden image, each
under its own license, as the wheels the publishers released. This project publishes no image (section
3.3); whoever copies a golden image to another machine takes on the license
terms of the components inside it.
The GeoIP database in the golden image is data, and its licenses are the data
sources', not the code license of the project that merges them:
`daijro/geoip-all-in-one` carries a GPL-3.0 `LICENSE` file for its scripts and
states no license for the file it publishes, which merges IP2Location LITE and
IPinfo and IPLocate.io (CC BY-SA 4.0), MaxMind GeoLite2 (its End User License
Agreement), DB-IP Lite (CC BY 4.0), the GeoFeed + Whois + ASN country database of
`tdulcet/ip-geolocation-dbs` (CC0 1.0) and, for the time zone computed from the
coordinates, OpenStreetMap-derived boundaries (ODbL 1.0). The golden image is built
by the person who runs Dots, on their own machine, from the pinned release, so
this project does not redistribute the data. The sources, licenses and credit
lines are `guest/image-builder/src/geoip-notices.ts`, which each golden manifest
records as `notices` together with `notices_statement`, and
`THIRD_PARTY_NOTICES.md` repeats; a test keeps the three equal.
Nothing under the GPL is installed by default (section 9.8, WhatsApp). The few LGPL packages
of the lock file are the prebuilt image libraries (`@img/sharp-*`) that Next.js may
install; the notices name them.
+5 -3
View File
@@ -79,9 +79,11 @@ image's SHA-256; `invisible-dots doctor` checks an image against it with
builds there only with such a cached archive. The cache is bounded: after
a build has its pin's archive, the two archives downloaded most recently
before it stay and older ones are removed (`pruneGeoipArchives`). The database merges free
editions of third-party databases that ask to be credited: their credits
are in `src/geoip-notices.ts`, which each golden manifest records as
`notices` and `THIRD_PARTY_NOTICES.md` repeats. The
editions of third-party databases, each under its own data license and most
asking to be credited: the licenses and credits are in `src/geoip-notices.ts`,
which each golden manifest records as `notices` (with `notices_statement`:
the image is built on your machine and invisible_dots does not redistribute
the data) and `THIRD_PARTY_NOTICES.md` repeats. The
browser smoke runs the same script on the same pinned archive. The only
browser a Dot has is that server: a test refuses any other browser or browser
library among the apt packages, the lock and the build scripts. `provision.sh`
+22 -18
View File
@@ -1,20 +1,31 @@
/**
* The data inside the golden image that is not ours: the GeoIP database (pins.json `geoip`).
*
* `daijro/geoip-all-in-one` merges the free editions of several IP geolocation databases into one file, and each of
* those is published on the condition that the product using it credits it. The credit lines below are the ones the
* data sources publish (their own pages, and the READMEs of daijro/geoip-all-in-one, tdulcet/ip-geolocation-dbs and
* The file is `geoip-aio-all.mmdb` of one release of `daijro/geoip-all-in-one`, which merges the free editions of
* several IP geolocation databases (country codes, coordinates, and a time zone computed from the coordinates), and
* each of those is published under its own data license, most of them on the condition that the product using it
* credits the publisher. `GEOIP_NOTICES` lists them with the license and the credit line each publisher asks for (the
* publisher's own page, and the READMEs of daijro/geoip-all-in-one, tdulcet/ip-geolocation-dbs and
* sapics/ip-location-db that list them). They are the one place the wording lives: every golden manifest records
* them (`notices`), and THIRD_PARTY_NOTICES.md must carry the same text (tests/repo/third-party-notices.test.ts).
* The first entry is the project that merges them and publishes the file the image carries, daijro/geoip-all-in-one:
* its repository declares the GPL-3.0 (GitHub's license field, and invisible_core says the same where it explains why
* it downloads the file instead of bundling it); it asks for no credit line, so the attribution names the project.
* The last two entries have no publisher's line to copy: CC0 asks for no credit, and the ODbL's own is
* them (`notices`) with `GEOIP_STATEMENT` (`notices_statement`), and THIRD_PARTY_NOTICES.md must carry the same text
* (tests/repo/third-party-notices.test.ts).
*
* What the list does not hold is a license for the merged file itself, because there is none to name:
* daijro/geoip-all-in-one carries a GPL-3.0 LICENSE file, which is the license of its scripts, and its README states
* no license for the data it publishes, only the credits three of its sources ask for. So the data licenses are
* the sources', and the statement says so, instead of calling the file GPL because GitHub shows that license on the
* repository. The last two entries have no publisher's line to copy: CC0 asks for no credit, and the ODbL's own is
* "(c) OpenStreetMap contributors" (the time zone of an address is computed from its coordinates by tzfpy).
* The image is built on the host of whoever uses it and is not distributed by this project, so these notices are
* what a person who runs Dots can read about what the image holds.
*
* The image is built by the person who runs Dots, on their own machine, from the pinned release that their machine
* downloads; invisible_dots publishes no image and no copy of the data, so it does not redistribute it. These notices
* are what a person who runs Dots can read about what the image holds, and what they owe if they share it.
*/
/** What the golden manifest and THIRD_PARTY_NOTICES.md say about the GeoIP data as a whole, next to the per-source list. */
export const GEOIP_STATEMENT =
"The GeoIP database in the golden image is the file geoip-aio-all.mmdb of one pinned release of daijro/geoip-all-in-one. It is downloaded and built into the image by the person who runs the build, on their own machine; invisible_dots does not publish or redistribute the image or the data. The file merges the databases listed here, and its project states no license for the merged file (the GPL-3.0 in its repository is the license of its scripts), so each source's own license applies to what it contributed: CC BY-SA 4.0 for IP2Location LITE, IPinfo and IPLocate.io (adaptations must be shared under the same license, with credit), the MaxMind GeoLite2 End User License Agreement, CC BY 4.0 for DB-IP Lite, CC0 1.0, and the ODbL 1.0 for the OpenStreetMap-derived time zone boundaries. Whoever shares the image or the file has to meet all of those terms.";
export interface DataNotice {
/** What in the image the data is part of. */
component: "geoip";
@@ -27,13 +38,6 @@ export interface DataNotice {
}
export const GEOIP_NOTICES: readonly DataNotice[] = [
{
component: "geoip",
source: "daijro/geoip-all-in-one (https://github.com/daijro/geoip-all-in-one), which merges the databases below and publishes the file the image carries",
license: "GPL-3.0",
license_url: "https://www.gnu.org/licenses/gpl-3.0.html",
attribution: "The file geoip-aio-all.mmdb is published by daijro/geoip-all-in-one (https://github.com/daijro/geoip-all-in-one) under the GPL-3.0.",
},
{
component: "geoip",
source: "IP2Location LITE (https://lite.ip2location.com)",
@@ -71,7 +75,7 @@ export const GEOIP_NOTICES: readonly DataNotice[] = [
},
{
component: "geoip",
source: "GeoFeed + Whois + ASN country database of tdulcet/ip-geolocation-dbs, built from sapics/ip-location-db",
source: "GeoFeed + Whois + ASN country database of tdulcet/ip-geolocation-dbs, built from sapics/ip-location-db, which lists the same data under PDDL 1.0",
license: "CC0 1.0",
license_url: "https://creativecommons.org/publicdomain/zero/1.0/",
attribution: "No credit is required; the data comes from tdulcet/ip-geolocation-dbs (https://github.com/tdulcet/ip-geolocation-dbs).",
+2 -1
View File
@@ -11,7 +11,7 @@ import { writeIso } from "@invisible-dots/iso";
import { hostPaths, replaceFile, type HostPaths } from "@invisible-dots/shared";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_PROVISION, BUILDER_PYTHON_LOCK, BUILDER_USER_DATA, defaultAssetRoot, readGuestAsset } from "./assets.js";
import { DownloadError, fetchVerified, sha256File, type Fetch, type FetchVerifiedOptions } from "./download.js";
import { GEOIP_NOTICES } from "./geoip-notices.js";
import { GEOIP_NOTICES, GEOIP_STATEMENT } from "./geoip-notices.js";
import { acquireLock, type Lock } from "./lock.js";
import { manifestPathFor, writeManifest, type GoldenManifest } from "./manifest.js";
import { BASE_IMAGE, downloadFileName, geoipCacheName, GUEST_PINS, isGeoipCacheName, type BaseImagePin, type GeoipPin, type GuestPins, type PinnedDownload } from "./pins.js";
@@ -250,6 +250,7 @@ async function buildLocked(options: GoldenBuildOptions & Resolved, target: Targe
apt_packages: [...pins.apt_packages],
},
notices: [...GEOIP_NOTICES],
notices_statement: GEOIP_STATEMENT,
engine: { lock_sha256: createHash("sha256").update(target.engineLock).digest("hex") },
installed: installedComponents(consoleText),
builder: { accelerator: options.accelerator },
+1 -1
View File
@@ -11,7 +11,7 @@
*/
export { defaultAssetRoot, GUEST_ASSETS, GUEST_UNITS } from "./assets.js";
export { checksumFromSums, DownloadError, fetchVerified, sha256File, type Fetch, type FetchVerifiedOptions, type VerifiedFile } from "./download.js";
export { GEOIP_NOTICES, type DataNotice } from "./geoip-notices.js";
export { GEOIP_NOTICES, GEOIP_STATEMENT, type DataNotice } from "./geoip-notices.js";
export { buildGoldenImage, GOLDEN_DEFAULTS, GoldenBuildError, type GoldenBuildOptions, type GoldenBuildResult } from "./golden.js";
export {
manifestPathFor,
+5 -2
View File
@@ -44,10 +44,13 @@ export interface GoldenManifest {
apt_packages: string[];
};
/**
* The credits that the data inside the image asks for (the GeoIP database): what a person who runs Dots may read about it.
* Absent from the manifest of an image built before they were recorded, which `readManifest` still reads as it is.
* The licenses and credits of the data inside the image (the GeoIP database), source by source: what a person who
* runs Dots may read about it. Absent from the manifest of an image built before they were recorded, which
* `readManifest` still reads as it is.
*/
notices?: DataNotice[];
/** What those notices mean together: who built the image, that invisible_dots redistributes nothing, and what whoever shares it owes. Recorded with `notices`. */
notices_statement?: string;
/** The engine's Python environment (builder/engine-requirements.lock): the lock the runtime disk's copy must equal. */
engine: { lock_sha256: string };
/** What the provisioner reported it installed (node, uv, browser-engine, ubuntu, kernel, ...). */
+3 -2
View File
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
import { hostPaths, type HostPaths } from "@invisible-dots/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_PROVISION, BUILDER_PYTHON_LOCK, BUILDER_USER_DATA, defaultAssetRoot } from "../src/assets.js";
import { GEOIP_NOTICES } from "../src/geoip-notices.js";
import { GEOIP_NOTICES, GEOIP_STATEMENT } from "../src/geoip-notices.js";
import { buildGoldenImage, GOLDEN_DEFAULTS, GoldenBuildError, type GoldenBuildOptions } from "../src/golden.js";
import { readManifest, verifyImage, type GoldenManifest } from "../src/manifest.js";
import type { BaseImagePin, GuestPins } from "../src/pins.js";
@@ -148,8 +148,9 @@ describe("buildGoldenImage", () => {
"invisible-playwright": "0.25.7",
apt_packages: ["xvfb", "imagemagick"],
},
// What the GeoIP data asks to be credited with travels with the image.
// What the GeoIP data asks to be credited with, and what that means together, travel with the image.
notices: [...GEOIP_NOTICES],
notices_statement: GEOIP_STATEMENT,
// The engine's lock, which the runtime disk's copy must equal.
engine: { lock_sha256: sha256(await readFile(join(defaultAssetRoot(), BUILDER_ENGINE_LOCK))) },
installed: { node: "v24.21.0", "browser-engine": "151.0" },
@@ -36,6 +36,7 @@ describe("readManifest", () => {
expect(manifest.kind).toBe("golden");
expect(manifest.kind === "golden" ? manifest.notices : "not golden").toBeUndefined();
expect(manifest.kind === "golden" ? manifest.notices_statement : "not golden").toBeUndefined();
});
});
+1379
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
{
"name": "invisible-dots-whatsapp-client",
"version": "0.0.0",
"private": true,
"description": "The WhatsApp client (Baileys) that the opt-in WhatsApp adapter loads. Not part of the default install: it depends on libsignal, which is GPL-3.0. Install it with `npm run whatsapp:install` from the repository root.",
"license": "MIT",
"type": "module",
"dependencies": {
"baileys": "7.0.0-rc14"
}
}
+6 -564
View File
@@ -211,16 +211,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
@@ -234,42 +224,6 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
"integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.5.0",
"@keyv/bigmap": "^1.3.1",
"hookified": "^1.15.1",
"keyv": "^5.6.0"
}
},
"node_modules/@cacheable/node-cache": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
"integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
"license": "MIT",
"dependencies": {
"cacheable": "^2.3.1",
"hookified": "^1.14.0",
"keyv": "^5.5.5"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@cacheable/utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
"integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
"license": "MIT",
"dependencies": {
"hashery": "^1.5.1",
"keyv": "^5.6.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
@@ -1041,26 +995,12 @@
"integrity": "sha512-iq1Qrq1iPKkB8yAa0qSuIURMZOCuqTY5pWy5gHpCeL1oQ+GPadGhw/cDTVE8waJwuCzacUzuIjRv1sESvk7u7A==",
"license": "MIT"
},
"node_modules/@hapi/boom": {
"version": "9.1.4",
"resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
"integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "9.x.x"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -1702,28 +1642,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@keyv/bigmap": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
"license": "MIT",
"dependencies": {
"hashery": "^1.4.0",
"hookified": "^1.15.0"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"keyv": "^5.6.0"
}
},
"node_modules/@keyv/serialize": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
@@ -1903,63 +1821,6 @@
"node": ">=20"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/number": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
@@ -4393,29 +4254,6 @@
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -4493,6 +4331,7 @@
"version": "24.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
"integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -5052,15 +4891,6 @@
"node": ">=12"
}
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -5100,86 +4930,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/baileys": {
"version": "7.0.0-rc14",
"resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc14.tgz",
"integrity": "sha512-pewtrljhWx5JTUBvvkXZz1fL3JPiwzjBsnhx/DWf2LWBx1cZNH7J/sF22xDehba5mySWUQU4a1Pwt7lWARUxaA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@cacheable/node-cache": "^1.4.0",
"@hapi/boom": "^9.1.3",
"async-mutex": "^0.5.0",
"libsignal": "^6.0.0",
"lru-cache": "^11.1.0",
"music-metadata": "^11.12.3",
"p-queue": "^9.0.0",
"pino": "^9.6",
"protobufjs": "^7.5.6",
"whatsapp-rust-bridge": "0.5.4",
"ws": "^8.13.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"audio-decode": "^2.1.3",
"jimp": "^1.6.1",
"link-preview-js": "^3.0.0",
"sharp": "*"
},
"peerDependenciesMeta": {
"audio-decode": {
"optional": true
},
"jimp": {
"optional": true
},
"link-preview-js": {
"optional": true
}
}
},
"node_modules/baileys/node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/baileys/node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/baileys/node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
@@ -5202,19 +4952,6 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/cacheable": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
"integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
"license": "MIT",
"dependencies": {
"@cacheable/memory": "^2.2.0",
"@cacheable/utils": "^2.5.0",
"hookified": "^1.15.0",
"keyv": "^5.6.0",
"qified": "^0.10.1"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -5370,19 +5107,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -5416,12 +5140,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/curve25519-js": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
"integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
@@ -5533,6 +5251,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -5693,12 +5412,6 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -5836,24 +5549,6 @@
}
}
},
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/find-my-way": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz",
@@ -5936,18 +5631,6 @@
"node": "^12.20.0 || >=14.13.1"
}
},
"node_modules/hashery": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
"license": "MIT",
"dependencies": {
"hookified": "^1.15.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -5988,12 +5671,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hookified": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
@@ -6017,26 +5694,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -6244,25 +5901,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/libsignal": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/libsignal/-/libsignal-6.0.0.tgz",
"integrity": "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==",
"license": "GPL-3.0",
"dependencies": {
"curve25519-js": "^0.0.4",
"protobufjs": "^7.5.5"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -6597,12 +6235,6 @@
"node": ">=8"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -6617,6 +6249,7 @@
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -6946,19 +6579,6 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz",
"integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/micromark": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
@@ -7549,37 +7169,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/music-metadata": {
"version": "11.16.1",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.16.1.tgz",
"integrity": "sha512-uR/mHK6eyfl8h3jVCobF5b38Mfg1IDJMiBxIVbmm5F+G1OXEFcUpQHveO84tJBAqvY93GCRY2R4kDyx7s06Rug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Borewit"
},
{
"type": "buymeacoffee",
"url": "https://buymeacoffee.com/borewit"
}
],
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.2",
"@tokenizer/token": "^0.3.0",
"content-type": "^2.1.0",
"debug": "^4.4.3",
"file-type": "^21.3.4",
"media-typer": "^2.0.0",
"strtok3": "^10.3.5",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0",
"win-guid": "^0.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -7749,34 +7338,6 @@
"node": ">=8"
}
},
"node_modules/p-queue": {
"version": "9.3.3",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
"integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4",
"p-timeout": "^7.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz",
"integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -8138,29 +7699,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/protobufjs": {
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8171,24 +7709,6 @@
"node": ">=6"
}
},
"node_modules/qified": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
"integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
"license": "MIT",
"dependencies": {
"hookified": "^2.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/qified/node_modules/hookified": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -8667,6 +8187,7 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -8811,22 +8332,6 @@
"node": ">=8"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -8983,24 +8488,6 @@
"node": ">=20"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
@@ -9110,18 +8597,6 @@
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/uint8array-extras": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz",
"integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
@@ -9136,6 +8611,7 @@
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -9492,12 +8968,6 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatsapp-rust-bridge": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz",
"integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==",
"license": "MIT"
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
@@ -9537,12 +9007,6 @@
"node": ">=20.11"
}
},
"node_modules/win-guid": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@@ -9557,27 +9021,6 @@
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -9688,7 +9131,6 @@
"@invisible-dots/events": "0.0.0",
"@invisible-dots/scheduler": "0.0.0",
"@invisible-dots/shared": "0.0.0",
"baileys": "7.0.0-rc14",
"grammy": "1.46.0"
}
},
+3
View File
@@ -16,6 +16,9 @@
"scripts": {
"typecheck": "tsc -p tsconfig.json && tsc -p apps/web/tsconfig.json && tsc -p apps/web/e2e/tsconfig.json",
"test": "vitest run",
"whatsapp:install": "npm ci --prefix optional/whatsapp --ignore-scripts --no-audit --no-fund",
"typecheck:whatsapp": "tsc -p tsconfig.whatsapp.json",
"test:whatsapp": "vitest run --config vitest.whatsapp.config.ts",
"build": "npm run build --workspaces --if-present"
},
"devDependencies": {
-1
View File
@@ -17,7 +17,6 @@
"@invisible-dots/events": "0.0.0",
"@invisible-dots/scheduler": "0.0.0",
"@invisible-dots/shared": "0.0.0",
"baileys": "7.0.0-rc14",
"grammy": "1.46.0"
}
}
+2 -2
View File
@@ -12,7 +12,6 @@ import { isUniqueViolation } from "@invisible-dots/database";
import type { EventLog } from "@invisible-dots/events";
import {
CHANNEL_KINDS,
ENV,
type ApprovalRecord,
newId,
type ChannelChange,
@@ -33,6 +32,7 @@ import { LinkSessions } from "./link.js";
import { hashPairingCode, newPairingCode } from "./pairing.js";
import { RateLimiter } from "./rate.js";
import { BindingRunner } from "./runner.js";
import { WHATSAPP_ENABLE_HELP } from "./whatsapp-baileys/client.js";
/** What the hub asks of the control plane; the Scheduler satisfies it. */
export interface ChannelHost {
@@ -376,7 +376,7 @@ export class ChannelHub {
if (!type) {
const off =
kind === "whatsapp"
? `it is off: set ${ENV.WHATSAPP}=1 and restart the server to turn it on (read its risks in the architecture document first)`
? `it is off: ${WHATSAPP_ENABLE_HELP} (read its risks in the architecture document first)`
: "this server has no adapter for it";
const known = CHANNEL_KINDS.includes(kind) ? off : `the channels are ${CHANNEL_KINDS.join(", ")}`;
throw bad(`no "${String(kind)}" channel: ${known}`);
+1
View File
@@ -6,4 +6,5 @@ export * from "./pairing.js";
export * from "./text.js";
export * from "./link.js";
export * from "./telegram/telegram.js";
export * from "./whatsapp-baileys/client.js";
export * from "./whatsapp-baileys/whatsapp.js";
@@ -2,7 +2,8 @@
* Baileys' authentication state over the encrypted secrets of the Dot, instead of the plaintext JSON files of
* its multi-file helper. The state is the linked device's identity and its Signal keys: whoever holds it is
* that WhatsApp account, which makes it worth more than a bot token. It is kept as one encrypted secret for the
* credentials and one per group of keys (`SignalDataTypeMap` names the groups), under the Dot's id like
* credentials and one per group of keys (`SignalKeyGroup` names the groups, and the typecheck made with the
* library installed proves the list is the library's own), under the Dot's id like
* every secret, so it is deleted with the channel and the Dot and never reaches the guest.
*
* The whole state lives in memory while the connection is open; a change is written before `set` resolves,
@@ -10,15 +11,15 @@
* that changed go in one transaction (`ChannelSecrets.putAll`), so a crash leaves the state as it was or as it
* is now, never a step apart; and once the channel or its Dot is deleted nothing is written back.
*/
import type { AuthenticationCreds, AuthenticationState, SignalDataSet, SignalDataTypeMap } from "baileys";
import type { ChannelSecrets } from "../channel.js";
import type { SignalKeyData, SignalKeyGroup, WhatsAppAuthState, WhatsAppClient, WhatsAppCreds } from "./client.js";
export const WHATSAPP_CREDS_SECRET = "whatsapp_creds";
type KeyGroup = keyof SignalDataTypeMap;
type KeyGroup = SignalKeyGroup;
/** One secret per group of keys. A `Record` over the library's own key list, so a group added by a Baileys upgrade is a compile error here, not a key that is never saved. */
const GROUP_SECRETS: Record<KeyGroup, string> = {
/** One secret per group of keys. A `Record` over the key list, so a group missing here is a compile error, not a key that is never saved; the typecheck made with the library installed proves the list is the library's. */
export const GROUP_SECRETS: Record<KeyGroup, string> = {
"pre-key": "whatsapp_keys_pre_key",
session: "whatsapp_keys_session",
"sender-key": "whatsapp_keys_sender_key",
@@ -34,16 +35,13 @@ const GROUP_SECRETS: Record<KeyGroup, string> = {
/** Every secret the state is kept in: what the hub deletes when the channel goes. */
export const WHATSAPP_AUTH_SECRETS: readonly string[] = [WHATSAPP_CREDS_SECRET, ...Object.values(GROUP_SECRETS)];
/** The parts of Baileys the state needs; the caller passes the module it loaded, so this file never imports it. */
export interface BaileysAuthLib {
initAuthCreds(): AuthenticationCreds;
BufferJSON: { replacer(key: string, value: unknown): unknown; reviver(key: string, value: unknown): unknown };
proto: { Message: { AppStateSyncKeyData: { fromObject(object: { [key: string]: unknown }): SignalDataTypeMap["app-state-sync-key"] } } };
}
/** The parts of Baileys the state needs; the caller passes the module it loaded, so this file never loads it. */
export type BaileysAuthLib<Creds extends WhatsAppCreds = WhatsAppCreds, KeyData extends SignalKeyData = SignalKeyData> = Pick<WhatsAppClient<Creds, KeyData>, "initAuthCreds" | "BufferJSON" | "proto">;
export class AuthStore {
/** The store keeps what the library handed it as it was; `Creds` and `KeyData` are the library's own types of it (the defaults are the adapter's loose view). */
export class AuthStore<Creds extends WhatsAppCreds = WhatsAppCreds, KeyData extends SignalKeyData = SignalKeyData> {
/** What `makeWASocket` takes as `auth`. */
readonly state: AuthenticationState;
readonly state: WhatsAppAuthState<Creds, KeyData>;
readonly #groups = new Map<KeyGroup, Record<string, unknown>>();
readonly #dirty = new Set<KeyGroup>();
#credsDirty = false;
@@ -53,24 +51,24 @@ export class AuthStore {
private constructor(
private readonly dotId: string,
private readonly secrets: ChannelSecrets,
private readonly lib: BaileysAuthLib,
creds: AuthenticationCreds,
private readonly lib: BaileysAuthLib<Creds, KeyData>,
creds: Creds,
) {
this.state = {
creds,
keys: {
get: async (type, ids) => {
const group = this.#groups.get(type) ?? {};
const found: Record<string, never> = {};
const found: Record<string, unknown> = {};
for (const id of ids) {
const value = group[id];
if (value === undefined) continue;
// The group is stored as plain JSON; this one is a protobuf message to Baileys.
(found as Record<string, unknown>)[id] = type === "app-state-sync-key" ? this.lib.proto.Message.AppStateSyncKeyData.fromObject(value as { [key: string]: unknown }) : value;
found[id] = type === "app-state-sync-key" ? this.lib.proto.Message.AppStateSyncKeyData.fromObject(value as { [key: string]: unknown }) : value;
}
return found;
},
set: async (data: SignalDataSet) => {
set: async (data) => {
this.#assertOpen();
for (const [type, entries] of Object.entries(data) as [KeyGroup, Record<string, unknown>][]) {
const group = this.#groups.get(type) ?? {};
@@ -93,7 +91,7 @@ export class AuthStore {
const text = await secrets.get(dotId, name);
return text === null ? null : JSON.parse(text, lib.BufferJSON.reviver);
};
const creds = ((await read(WHATSAPP_CREDS_SECRET)) as AuthenticationCreds | null) ?? lib.initAuthCreds();
const creds = ((await read(WHATSAPP_CREDS_SECRET)) as WhatsAppCreds | null) ?? lib.initAuthCreds();
const store = new AuthStore(dotId, secrets, lib, creds);
for (const [group, name] of Object.entries(GROUP_SECRETS) as [KeyGroup, string][]) {
const stored = (await read(name)) as Record<string, unknown> | null;
@@ -3,21 +3,22 @@
* links the Dot as a device of a WhatsApp account, which WhatsApp does not allow for automation and can end with
* the account banned. It is pinned to one exact release candidate because the protocol changes under it.
*
* This file is the only one that knows Baileys, and it loads it when a connection opens (`import()`), so a server
* that never links WhatsApp never loads it. It translates and decides nothing: who may talk, what is answered
* This file is the only one that talks to Baileys, through `loadWhatsAppClient` when a connection opens (see
* `client.ts`: the library is an opt-in install, and a server that never links WhatsApp never loads it). It
* translates and decides nothing: who may talk, what is answered
* and how a person is told apart are the channel's (`WhatsAppChannel`). The real network is not reached by any
* test; what is tested is `toIncoming`, `endOf` and the auth state, which carry the protocol's traps (LID
* addresses, the many ways a connection closes).
*/
import type { WAMessage, WASocket } from "baileys";
import type { ChannelSecrets } from "../channel.js";
import { AuthStore, type BaileysAuthLib } from "./auth-state.js";
import { AuthStore } from "./auth-state.js";
import { loadWhatsAppClient, type WhatsAppClient, type WhatsAppLogger, type WhatsAppMessage, type WhatsAppSocket, type WhatsAppSocketConfig } from "./client.js";
import { isDirectJid, phoneOf } from "./jid.js";
import type { WhatsAppConnection, WhatsAppConnector, WhatsAppContent, WhatsAppEnd, WhatsAppEvents, WhatsAppIncoming } from "./port.js";
type Baileys = typeof import("baileys");
export interface BaileysOptions {
/** Where the opt-in client is installed (`whatsappClientDir`). */
clientDir: string;
/** The WebSocket address of WhatsApp; default Baileys'. Tests point it at nothing. */
waWebSocketUrl?: string;
/** The WhatsApp Web version to present; default the one Baileys was released with. When WhatsApp stops accepting it, the release has to be upgraded. */
@@ -42,7 +43,7 @@ const ATTACHMENT_KEYS = [
] as const;
/** Baileys logs through pino; its lines hold addresses and ids of people, so they are not kept. What matters reaches the channel as `end`. */
const SILENT_LOGGER = {
const SILENT_LOGGER: WhatsAppLogger = {
level: "silent",
child() {
return SILENT_LOGGER;
@@ -63,8 +64,25 @@ export function textContent(text: string): { text: string; linkPreview: null } {
return { text, linkPreview: null };
}
/** The options of the socket: the one place they are decided, and the one the typecheck made with the library installed holds against the library's own type (`test-optin/shape.ts`). */
export function socketConfig<Auth>(auth: Auth, options: Pick<BaileysOptions, "version" | "waWebSocketUrl">): WhatsAppSocketConfig<Auth> {
return {
auth,
browser: BROWSER,
logger: SILENT_LOGGER,
// Only the chats of single people: a group's members would see what the Dot says, and nothing here answers a group.
shouldIgnoreJid: (jid: string) => !isDirectJid(jid),
shouldSyncHistoryMessage: () => false,
syncFullHistory: false,
markOnlineOnConnect: false,
generateHighQualityLinkPreview: false,
...(options.version && { version: options.version }),
...(options.waWebSocketUrl && { waWebSocketUrl: options.waWebSocketUrl }),
};
}
/** What a Baileys message is to the channel. Null for one that has no id or no chat, which no one could answer. */
export function toIncoming(message: WAMessage, lib: Pick<Baileys, "normalizeMessageContent">): WhatsAppIncoming | null {
export function toIncoming(message: WhatsAppMessage, lib: Pick<WhatsAppClient, "normalizeMessageContent">): WhatsAppIncoming | null {
const { id, remoteJid, remoteJidAlt, fromMe } = message.key;
if (!id || !remoteJid) return null;
const inner = lib.normalizeMessageContent(message.message);
@@ -107,26 +125,14 @@ export class BaileysConnector implements WhatsAppConnector {
constructor(
private readonly dotId: string,
private readonly secrets: ChannelSecrets,
private readonly options: BaileysOptions = {},
private readonly options: BaileysOptions,
) {}
async connect(events: WhatsAppEvents): Promise<WhatsAppConnection> {
const lib: Baileys = await import("baileys");
const auth = await AuthStore.open(this.dotId, this.secrets, lib as unknown as BaileysAuthLib);
const lib = await loadWhatsAppClient(this.options.clientDir);
const auth = await AuthStore.open(this.dotId, this.secrets, lib);
let closing = false;
const socket = lib.makeWASocket({
auth: auth.state,
browser: BROWSER,
logger: SILENT_LOGGER,
// Only the chats of single people: a group's members would see what the Dot says, and nothing here answers a group.
shouldIgnoreJid: (jid) => !isDirectJid(jid),
shouldSyncHistoryMessage: () => false,
syncFullHistory: false,
markOnlineOnConnect: false,
generateHighQualityLinkPreview: false,
...(this.options.version && { version: this.options.version }),
...(this.options.waWebSocketUrl && { waWebSocketUrl: this.options.waWebSocketUrl }),
});
const socket = lib.makeWASocket(socketConfig(auth.state, this.options));
socket.ev.on("creds.update", () => {
// A failed write is the connection's failure: the keys it used cannot be taken back.
@@ -155,7 +161,7 @@ export class BaileysConnector implements WhatsAppConnector {
export class BaileysConnection implements WhatsAppConnection {
constructor(
private readonly socket: WASocket,
private readonly socket: WhatsAppSocket,
private readonly auth: AuthStore,
private readonly beginClosing: () => void,
) {}
@@ -0,0 +1,212 @@
/**
* The WhatsApp client (Baileys, MIT) is not part of the default install: it depends on `libsignal`, which is GPL-3.0,
* and nothing under a GPL license is installed unless the person asks for it. It lives apart from the workspaces, in
* `optional/whatsapp/` (its own `package.json` and lock file, every package with its integrity hash), and one
* command installs it: `npm run whatsapp:install` from the repository root.
*
* This file is the only one that names the library. It looks for it in that folder when a connection opens, loads it
* only if it is there, and otherwise fails with the words that say how to enable it. What the adapter uses of the
* library is written down here as `WhatsAppClient`: the adapter is compiled without the library installed, and a
* typecheck made with it installed (`npm run typecheck:whatsapp`, a job of CI) proves the real module has this shape,
* that the socket options the adapter passes are options the library has, with the types it gives them, and that the
* adapter's auth state is one the library accepts. The library's types come in as type parameters (`Creds`, `KeyData`),
* which that typecheck fills with the library's own; the adapter uses the defaults.
*
* The one version of the library is the one `optional/whatsapp/package.json` declares, exactly. What is installed is
* compared with it every time the client is looked for, because a pull that moves the pin leaves the old release in
* `node_modules` until the person installs again.
*/
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { ENV } from "@invisible-dots/shared";
/** The one command that installs the client. A script of the root `package.json`. */
export const WHATSAPP_INSTALL_COMMAND = "npm run whatsapp:install";
/** Where the client is installed, under the repository root. */
export function whatsappClientDir(repoRoot: string): string {
return join(repoRoot, "optional", "whatsapp");
}
/** The words that say how to enable WhatsApp; the one place they are written. */
export const WHATSAPP_ENABLE_HELP = `run \`${WHATSAPP_INSTALL_COMMAND}\` in the invisible_dots folder to install the client, then start the server with ${ENV.WHATSAPP}=1`;
/** The client is not installed. The message names the fix; retrying finds it as soon as the person has done it. */
export class WhatsAppClientMissingError extends Error {
constructor() {
super(`The WhatsApp client is not installed. It is optional because it depends on libsignal, which is GPL-3.0: ${WHATSAPP_ENABLE_HELP}.`);
this.name = "WhatsAppClientMissingError";
}
}
/** A client is installed, but not the release this checkout pins: it was installed before the pin moved, or by hand. The message names the fix, the same command. */
export class WhatsAppClientVersionError extends Error {
constructor(
readonly pinned: string | null,
readonly installed: string | null,
) {
super(`The installed WhatsApp client is ${installed ?? "of an unknown version"}, and this checkout pins baileys ${pinned ?? "at no version"} (optional/whatsapp/package.json): ${WHATSAPP_ENABLE_HELP}.`);
this.name = "WhatsAppClientVersionError";
}
}
/** The signalling keys a linked device keeps, one secret each. */
export type SignalKeyGroup =
| "pre-key"
| "session"
| "sender-key"
| "sender-key-memory"
| "app-state-sync-key"
| "app-state-sync-version"
| "lid-mapping"
| "device-list"
| "tctoken"
| "identity-key";
/** The credentials of a linked device. The adapter reads one field (`me`, set once an account is linked) and stores the rest as the library made it. */
export interface WhatsAppCreds {
me?: unknown;
}
/** What the library keeps under each group of keys. The adapter does not look inside; the typecheck with the library installed sets it to the library's own map. */
export type SignalKeyData = Record<SignalKeyGroup, unknown>;
/** What `makeWASocket` takes as `auth`. */
export interface WhatsAppAuthState<Creds extends WhatsAppCreds = WhatsAppCreds, KeyData extends SignalKeyData = SignalKeyData> {
creds: Creds;
keys: {
get<G extends SignalKeyGroup>(type: G, ids: string[]): Promise<Record<string, KeyData[G]>>;
set(data: { [G in SignalKeyGroup]?: Record<string, KeyData[G] | null> }): Promise<void>;
};
}
/** What the adapter passes to `makeWASocket`: exactly these options, no other, so the typecheck with the library installed can prove each one is the library's. Built by `socketConfig` (baileys.ts). */
export interface WhatsAppSocketConfig<Auth = WhatsAppAuthState> {
auth: Auth;
/** What shows as the linked device under "Linked devices" on the phone. */
browser: [string, string, string];
logger: WhatsAppLogger;
shouldIgnoreJid: (jid: string) => boolean;
shouldSyncHistoryMessage: () => boolean;
syncFullHistory: boolean;
markOnlineOnConnect: boolean;
generateHighQualityLinkPreview: boolean;
/** The WhatsApp Web version to present; absent is the one the library was released with. */
version?: [number, number, number];
/** The WebSocket address of WhatsApp; absent is the library's. */
waWebSocketUrl?: string;
}
/** The logger the library calls (pino's shape). */
export interface WhatsAppLogger {
level: string;
child(bindings: Record<string, unknown>): WhatsAppLogger;
trace(...args: unknown[]): void;
debug(...args: unknown[]): void;
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
error(...args: unknown[]): void;
}
/** A message as the library hands it over. */
export interface WhatsAppMessage {
key: { id?: string | null; remoteJid?: string | null; remoteJidAlt?: string | null; fromMe?: boolean | null };
message?: WhatsAppMessageContent | null;
pushName?: string | null;
}
/** The parts of a message's content the adapter reads: text, and which kinds of attachment there are. */
export interface WhatsAppMessageContent {
conversation?: string | null;
extendedTextMessage?: { text?: string | null } | null;
imageMessage?: unknown;
videoMessage?: unknown;
audioMessage?: unknown;
documentMessage?: unknown;
stickerMessage?: unknown;
ptvMessage?: unknown;
contactMessage?: unknown;
contactsArrayMessage?: unknown;
locationMessage?: unknown;
liveLocationMessage?: unknown;
}
export interface WhatsAppConnectionUpdate {
qr?: string;
connection?: "connecting" | "open" | "close";
lastDisconnect?: { error?: unknown };
}
export interface WhatsAppSocket {
ev: {
on(event: "creds.update", listener: () => void): void;
on(event: "connection.update", listener: (update: WhatsAppConnectionUpdate) => void): void;
on(event: "messages.upsert", listener: (upsert: { messages: WhatsAppMessage[]; type: string }) => void): void;
};
user?: { id?: string } | undefined;
signalRepository: { lidMapping: { getPNForLID(lid: string): Promise<string | null | undefined> } };
sendMessage(jid: string, content: { text: string; linkPreview?: null; edit?: { remoteJid: string; id: string; fromMe: boolean } }): Promise<{ key: { id?: string | null } } | undefined>;
readMessages(keys: { remoteJid: string; id: string; fromMe: boolean }[]): Promise<void>;
sendPresenceUpdate(type: "composing", jid: string): Promise<void>;
end(error: Error | undefined): void | Promise<void>;
}
/**
* What the adapter uses of the Baileys module. Methods, not properties, so their parameters are compared loosely
* (in both directions) by the typecheck of `shape.ts`: what the adapter passes in is therefore checked there on its
* own, in one direction, for the socket options (`WhatsAppSocketConfig`) and the auth state (`WhatsAppAuthState`).
*/
export interface WhatsAppClient<Creds extends WhatsAppCreds = WhatsAppCreds, KeyData extends SignalKeyData = SignalKeyData> {
makeWASocket(config: WhatsAppSocketConfig<WhatsAppAuthState<Creds, KeyData>>): WhatsAppSocket;
normalizeMessageContent(content: WhatsAppMessageContent | null | undefined): WhatsAppMessageContent | undefined;
initAuthCreds(): Creds;
BufferJSON: { replacer(key: string, value: unknown): unknown; reviver(key: string, value: unknown): unknown };
proto: { Message: { AppStateSyncKeyData: { fromObject(object: { [key: string]: unknown }): KeyData["app-state-sync-key"] } } };
}
/** The path of the library's entry point under `dir`, or null when it is not installed there. */
function entryOf(dir: string): string | null {
if (!existsSync(join(dir, "node_modules", "baileys"))) return null;
try {
return createRequire(join(dir, "package.json")).resolve("baileys");
} catch {
return null;
}
}
/** A string field of a JSON file, null when the file or the field is not there. */
function readString(file: string, ...path: string[]): string | null {
try {
let value: unknown = JSON.parse(readFileSync(file, "utf8"));
for (const key of path) value = (value as Record<string, unknown> | null)?.[key];
return typeof value === "string" ? value : null;
} catch {
return null;
}
}
/** The entry point of a client that is installed and is the pinned release, else the reason it cannot be used. */
function inspect(dir: string): { entry: string } | { problem: WhatsAppClientMissingError | WhatsAppClientVersionError } {
const entry = entryOf(dir);
if (entry === null) return { problem: new WhatsAppClientMissingError() };
// The pin is what `optional/whatsapp/package.json` declares, one exact version; the installed release must be that one.
const pinned = readString(join(dir, "package.json"), "dependencies", "baileys");
const installed = readString(join(dir, "node_modules", "baileys", "package.json"), "version");
if (pinned === null || pinned !== installed) return { problem: new WhatsAppClientVersionError(pinned, installed) };
return { entry };
}
/** Why the client under `dir` cannot be loaded, or null when it can: the check `loadWhatsAppClient` makes, for whoever says so before a connection opens. */
export function whatsappClientProblem(dir: string): WhatsAppClientMissingError | WhatsAppClientVersionError | null {
const found = inspect(dir);
return "problem" in found ? found.problem : null;
}
/** Load the client from `dir`; throws `WhatsAppClientMissingError` when it is not installed there and `WhatsAppClientVersionError` when it is not the pinned release. */
export async function loadWhatsAppClient(dir: string): Promise<WhatsAppClient> {
const found = inspect(dir);
if ("problem" in found) throw found.problem;
return (await import(pathToFileURL(found.entry).href)) as WhatsAppClient;
}
@@ -35,13 +35,14 @@ import { BaileysConnector, type BaileysOptions } from "./baileys.js";
import { isDirectJid, lidJid, lidOf, phoneJid, phoneOf } from "./jid.js";
import type { WhatsAppConnection, WhatsAppConnector, WhatsAppEnd, WhatsAppEvents, WhatsAppIncoming } from "./port.js";
export interface WhatsAppOptions {
/** Makes the connector of a Dot's binding; default Baileys. The tests give it a fake. */
connector?: (dotId: string, secrets: ChannelSecrets) => WhatsAppConnector;
baileys?: BaileysOptions;
/** The connection is made by Baileys, loaded from where the opt-in client is installed, or by whatever `connector` makes (the tests give it a fake). */
export type WhatsAppOptions = (
| { connector: (dotId: string, secrets: ChannelSecrets) => WhatsAppConnector }
| { baileys: BaileysOptions }
) & {
/** How long to wait before a send, in milliseconds; default a random 0.4 to 1.5 seconds. */
pauseMs?: () => number;
}
};
const PAIRING_MESSAGE = /^pair\s+([A-Za-z0-9]+)$/i;
/** More restarts than this in a row, with no connection in between, is a loop and not a link finishing. */
@@ -224,10 +225,10 @@ export class WhatsAppChannelType implements ChannelType {
readonly scrubNames = [] as const;
readonly scanned = true as const;
constructor(private readonly options: WhatsAppOptions = {}) {}
constructor(private readonly options: WhatsAppOptions) {}
async create(binding: ChannelBindingRecord, secrets: ChannelSecrets): Promise<Channel> {
const connector = this.options.connector?.(binding.dot_id, secrets) ?? new BaileysConnector(binding.dot_id, secrets, this.options.baileys);
const connector = "connector" in this.options ? this.options.connector(binding.dot_id, secrets) : new BaileysConnector(binding.dot_id, secrets, this.options.baileys);
return new WhatsAppChannel(connector, this.options.pauseMs ?? defaultPause);
}
@@ -2,23 +2,31 @@
* The linked device's keys over the encrypted secrets, with the real Baileys helpers (`initAuthCreds`,
* `BufferJSON`, the protobuf of the app-state keys) and a real test database: what is written, what is
* encrypted, what a restart reads back, and what happens when a write fails or the session closes.
* Needs the opt-in client installed (`npm run whatsapp:install`): this suite is `npm run test:whatsapp`.
*/
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import type { AuthenticationState } from "baileys";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import type { ChannelSecrets } from "../src/channel.js";
import { AuthStore, WHATSAPP_AUTH_SECRETS, WHATSAPP_CREDS_SECRET, type BaileysAuthLib } from "../src/whatsapp-baileys/auth-state.js";
import { baileys, type Baileys } from "./client.js";
/** The state as Baileys types it: what `makeWASocket` receives is the library's own type, and the tests read it as such. */
const state = (store: AuthStore) => store.state as unknown as AuthenticationState;
describe.each(testAdapters())("the WhatsApp auth state over encrypted secrets (%s)", { timeout: 60_000 }, (kind) => {
let t: TestDatabase;
let db: Database;
let lib: BaileysAuthLib;
let real: Baileys;
const DOT = "dot_auth_test";
beforeAll(async () => {
t = await createTestDatabase(kind);
db = t.db;
lib = (await import("baileys")) as unknown as BaileysAuthLib;
real = await baileys;
lib = real;
}, 60_000);
afterEach(async () => {
@@ -55,19 +63,19 @@ describe.each(testAdapters())("the WhatsApp auth state over encrypted secrets (%
it("starts as a device that is not linked, with fresh credentials, when nothing is stored", async () => {
const store = await AuthStore.open(DOT, watched().secrets, lib);
expect(store.linked).toBe(false);
expect(store.state.creds.registered).toBe(false);
expect(store.state.creds.noiseKey.public).toBeInstanceOf(Uint8Array);
expect(state(store).creds.registered).toBe(false);
expect(state(store).creds.noiseKey.public).toBeInstanceOf(Uint8Array);
expect(await names()).toEqual([]);
});
it("keeps the credentials and each group of keys in a secret of its own, written when they change and encrypted at rest", async () => {
const w = watched();
const store = await AuthStore.open(DOT, w.secrets, lib);
await store.state.keys.set({ "pre-key": { "7": { public: Buffer.from("PUBLIC-PREKEY-BYTES"), private: Buffer.from("PRIVATE-PREKEY-BYTES") } } });
await state(store).keys.set({ "pre-key": { "7": { public: Buffer.from("PUBLIC-PREKEY-BYTES"), private: Buffer.from("PRIVATE-PREKEY-BYTES") } } });
expect(w.writes).toEqual(["whatsapp_keys_pre_key"]);
await store.state.keys.set({ session: { "393331112222.0": Buffer.from("SESSION-STATE-SECRET") } });
await state(store).keys.set({ session: { "393331112222.0": Buffer.from("SESSION-STATE-SECRET") } });
expect(w.writes).toEqual(["whatsapp_keys_pre_key", "whatsapp_keys_session"]);
store.state.creds.me = { id: "15550001111:[email protected]", name: "Dot" };
state(store).creds.me = { id: "15550001111:[email protected]", name: "Dot" };
await store.saveCreds();
expect(w.writes.at(-1)).toBe(WHATSAPP_CREDS_SECRET);
expect(await names()).toEqual(["whatsapp_creds", "whatsapp_keys_pre_key", "whatsapp_keys_session"]);
@@ -81,101 +89,101 @@ describe.each(testAdapters())("the WhatsApp auth state over encrypted secrets (%
it("reads back, after a restart, the same credentials and keys, as the types Baileys uses", async () => {
const first = await AuthStore.open(DOT, watched().secrets, lib);
await first.state.keys.set({
await state(first).keys.set({
"pre-key": { "1": { public: Buffer.from([1, 2, 3]), private: Buffer.from([4, 5, 6]) } },
session: { alice: Buffer.from("session-alice"), bob: Buffer.from("session-bob") },
"lid-mapping": { "393331112222": "99887766554433", "99887766554433_reverse": "393331112222" },
"app-state-sync-key": { AAAA: lib.proto.Message.AppStateSyncKeyData.fromObject({ keyData: Buffer.from("sync-key-bytes"), timestamp: 42 }) },
"app-state-sync-key": { AAAA: real.proto.Message.AppStateSyncKeyData.fromObject({ keyData: Buffer.from("sync-key-bytes"), timestamp: 42 }) },
});
first.state.creds.me = { id: "15550001111:[email protected]" };
first.state.creds.registered = true;
state(first).creds.me = { id: "15550001111:[email protected]" };
state(first).creds.registered = true;
await first.saveCreds();
await first.close();
const second = await AuthStore.open(DOT, watched().secrets, lib);
expect(second.linked).toBe(true);
expect(second.state.creds.registered).toBe(true);
expect(Buffer.from(second.state.creds.noiseKey.private).equals(Buffer.from(first.state.creds.noiseKey.private))).toBe(true);
expect(Buffer.from(second.state.creds.signedIdentityKey.public).equals(Buffer.from(first.state.creds.signedIdentityKey.public))).toBe(true);
expect(state(second).creds.registered).toBe(true);
expect(Buffer.from(state(second).creds.noiseKey.private).equals(Buffer.from(state(first).creds.noiseKey.private))).toBe(true);
expect(Buffer.from(state(second).creds.signedIdentityKey.public).equals(Buffer.from(state(first).creds.signedIdentityKey.public))).toBe(true);
const prekey = (await second.state.keys.get("pre-key", ["1", "missing"]))["1"]!;
const prekey = (await state(second).keys.get("pre-key", ["1", "missing"]))["1"]!;
expect(Buffer.from(prekey.public)).toEqual(Buffer.from([1, 2, 3]));
expect(Buffer.from(prekey.private)).toEqual(Buffer.from([4, 5, 6]));
const sessions = await second.state.keys.get("session", ["alice", "bob", "carol"]);
const sessions = await state(second).keys.get("session", ["alice", "bob", "carol"]);
expect(Object.keys(sessions)).toEqual(["alice", "bob"]);
expect(Buffer.from(sessions.alice!).toString()).toBe("session-alice");
expect(await second.state.keys.get("lid-mapping", ["99887766554433_reverse"])).toEqual({ "99887766554433_reverse": "393331112222" });
expect(await state(second).keys.get("lid-mapping", ["99887766554433_reverse"])).toEqual({ "99887766554433_reverse": "393331112222" });
// The app-state key comes back as the protobuf message Baileys reads, not as the plain object it was stored as.
const sync = (await second.state.keys.get("app-state-sync-key", ["AAAA"]))["AAAA"]!;
expect(sync).toBeInstanceOf(lib.proto.Message.AppStateSyncKeyData);
const sync = (await state(second).keys.get("app-state-sync-key", ["AAAA"]))["AAAA"]!;
expect(sync).toBeInstanceOf(real.proto.Message.AppStateSyncKeyData);
expect(Buffer.from(sync.keyData!).toString()).toBe("sync-key-bytes");
});
it("writes the credentials and the groups that changed together, so a crash cannot leave them a step apart", async () => {
const w = watched();
const store = await AuthStore.open(DOT, w.secrets, lib);
store.state.creds.me = { id: "15550001111:[email protected]" };
state(store).creds.me = { id: "15550001111:[email protected]" };
// Baileys saves the credentials and its keys in the same breath: they are one write.
await Promise.all([store.saveCreds(), store.state.keys.set({ session: { a: Buffer.from("ratchet") }, "pre-key": { "2": { public: Buffer.from("p"), private: Buffer.from("q") } } })]);
await Promise.all([store.saveCreds(), state(store).keys.set({ session: { a: Buffer.from("ratchet") }, "pre-key": { "2": { public: Buffer.from("p"), private: Buffer.from("q") } } })]);
expect(w.batches).toEqual([["whatsapp_creds", "whatsapp_keys_session", "whatsapp_keys_pre_key"]]);
// A write that fails writes none of them: the credentials stay as they were, with the keys that go with them.
const failing = watched({ failOn: "whatsapp_keys_session" });
const second = await AuthStore.open(DOT, failing.secrets, lib);
second.state.creds.me = { id: "15550001111:[email protected]" };
await expect(Promise.all([second.saveCreds(), second.state.keys.set({ session: { a: Buffer.from("next ratchet") } })])).rejects.toThrow(/refused the write/);
state(second).creds.me = { id: "15550001111:[email protected]" };
await expect(Promise.all([second.saveCreds(), state(second).keys.set({ session: { a: Buffer.from("next ratchet") } })])).rejects.toThrow(/refused the write/);
const stored = await AuthStore.open(DOT, watched().secrets, lib);
expect(stored.state.creds.me?.id).toBe("15550001111:[email protected]");
expect(Buffer.from((await stored.state.keys.get("session", ["a"]))["a"]!).toString()).toBe("ratchet");
expect(state(stored).creds.me?.id).toBe("15550001111:[email protected]");
expect(Buffer.from((await state(stored).keys.get("session", ["a"]))["a"]!).toString()).toBe("ratchet");
});
it("forgets a key that Baileys deletes, and writes only the groups that changed", async () => {
const w = watched();
const store = await AuthStore.open(DOT, w.secrets, lib);
await store.state.keys.set({ session: { a: Buffer.from("a"), b: Buffer.from("b") }, "pre-key": { "1": { public: Buffer.from("x"), private: Buffer.from("y") } } });
await state(store).keys.set({ session: { a: Buffer.from("a"), b: Buffer.from("b") }, "pre-key": { "1": { public: Buffer.from("x"), private: Buffer.from("y") } } });
w.writes.length = 0;
await store.state.keys.set({ session: { a: null } });
await state(store).keys.set({ session: { a: null } });
expect(w.writes).toEqual(["whatsapp_keys_session"]);
expect(Object.keys(await store.state.keys.get("session", ["a", "b"]))).toEqual(["b"]);
expect(Object.keys(await state(store).keys.get("session", ["a", "b"]))).toEqual(["b"]);
await store.close();
const reopened = await AuthStore.open(DOT, watched().secrets, lib);
expect(Object.keys(await reopened.state.keys.get("session", ["a", "b"]))).toEqual(["b"]);
expect(Object.keys(await state(reopened).keys.get("session", ["a", "b"]))).toEqual(["b"]);
});
it("writes in the order the changes were made, one at a time, so the last change is the one that stays", async () => {
const w = watched({ delayMs: (name) => (name === "whatsapp_keys_session" ? 20 : 0) });
const store = await AuthStore.open(DOT, w.secrets, lib);
await Promise.all([
store.state.keys.set({ session: { a: Buffer.from("one") } }),
store.state.keys.set({ session: { a: Buffer.from("two") } }),
store.state.keys.set({ session: { a: Buffer.from("three") } }),
state(store).keys.set({ session: { a: Buffer.from("one") } }),
state(store).keys.set({ session: { a: Buffer.from("two") } }),
state(store).keys.set({ session: { a: Buffer.from("three") } }),
]);
await store.close();
const reopened = await AuthStore.open(DOT, watched().secrets, lib);
expect(Buffer.from((await reopened.state.keys.get("session", ["a"]))["a"]!).toString()).toBe("three");
expect(Buffer.from((await state(reopened).keys.get("session", ["a"]))["a"]!).toString()).toBe("three");
});
it("fails the change when the write fails, keeps it in memory, and writes it with the next change that succeeds", async () => {
const w = watched({ failOn: "whatsapp_keys_session" });
const store = await AuthStore.open(DOT, w.secrets, lib);
await expect(store.state.keys.set({ session: { a: Buffer.from("kept") } })).rejects.toThrow(/refused the write/);
await expect(state(store).keys.set({ session: { a: Buffer.from("kept") } })).rejects.toThrow(/refused the write/);
expect(await names()).toEqual([]);
expect(Buffer.from((await store.state.keys.get("session", ["a"]))["a"]!).toString()).toBe("kept");
expect(Buffer.from((await state(store).keys.get("session", ["a"]))["a"]!).toString()).toBe("kept");
w.healed();
await store.state.keys.set({ "sender-key-memory": { g: { x: true } } });
await state(store).keys.set({ "sender-key-memory": { g: { x: true } } });
await store.close();
const reopened = await AuthStore.open(DOT, watched().secrets, lib);
expect(Buffer.from((await reopened.state.keys.get("session", ["a"]))["a"]!).toString()).toBe("kept");
expect(Buffer.from((await state(reopened).keys.get("session", ["a"]))["a"]!).toString()).toBe("kept");
});
it("waits for its writes when it closes, and refuses every later one: what the hub deletes stays deleted", async () => {
const w = watched({ delayMs: () => 30 });
const store = await AuthStore.open(DOT, w.secrets, lib);
const pending = store.state.keys.set({ session: { a: Buffer.from("late") } });
const pending = state(store).keys.set({ session: { a: Buffer.from("late") } });
await store.close();
await pending;
expect(await names()).toEqual(["whatsapp_keys_session"]);
await expect(store.state.keys.set({ session: { b: Buffer.from("too late") } })).rejects.toThrow(/closed/);
await expect(state(store).keys.set({ session: { b: Buffer.from("too late") } })).rejects.toThrow(/closed/);
await expect(store.saveCreds()).rejects.toThrow(/closed/);
await db.secrets.delete(DOT, "whatsapp_keys_session");
expect(await names()).toEqual([]);
+14
View File
@@ -0,0 +1,14 @@
/** The real Baileys module, loaded the way the adapter loads it: from the folder `npm run whatsapp:install` fills. */
import { fileURLToPath } from "node:url";
import { loadWhatsAppClient, whatsappClientDir } from "../src/whatsapp-baileys/client.js";
export type Baileys = typeof import("baileys");
/** The repository root. */
export const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
/** The client folder of this repository. */
export const CLIENT_DIR = whatsappClientDir(REPO_ROOT);
/** The module, typed as the library's own: the adapter's view of it (`WhatsAppClient`) is proved to fit by `shape.ts`. */
export const baileys: Promise<Baileys> = loadWhatsAppClient(CLIENT_DIR) as unknown as Promise<Baileys>;
@@ -0,0 +1,102 @@
/**
* The Baileys glue against the real library. WhatsApp itself cannot be reached from a test, so what is proved here is
* what the glue decides on its own: how a Baileys message is read (`toIncoming`, over messages built with Baileys' own
* protobuf classes and passed through its encoder and decoder, so they are the library's objects and not
* hand-made look-alikes), and that the connector survives a socket that cannot connect. The real network is not
* tested in CI. Needs the opt-in client installed (`npm run whatsapp:install`): this suite is `npm run test:whatsapp`.
*/
import type { WAMessage } from "baileys";
import { describe, expect, it } from "vitest";
import { BaileysConnector, toIncoming } from "../src/whatsapp-baileys/baileys.js";
import type { WhatsAppEnd, WhatsAppEvents, WhatsAppIncoming } from "../src/whatsapp-baileys/port.js";
import { baileys, CLIENT_DIR, type Baileys } from "./client.js";
/** A message as Baileys hands it over: the protobuf part through the real encoder and decoder, the address extras Baileys adds to the key on top. */
async function received(spec: { key: Record<string, unknown>; message?: Record<string, unknown>; pushName?: string }): Promise<WAMessage> {
const lib: Baileys = await baileys;
const info = lib.proto.WebMessageInfo.create({ key: { remoteJid: spec.key.remoteJid as string, id: spec.key.id as string, fromMe: spec.key.fromMe === true }, message: spec.message, pushName: spec.pushName });
const decoded = lib.proto.WebMessageInfo.decode(lib.proto.WebMessageInfo.encode(info).finish()) as WAMessage;
Object.assign(decoded.key, spec.key);
return decoded;
}
const read = async (spec: Parameters<typeof received>[0]): Promise<WhatsAppIncoming | null> => toIncoming(await received(spec), await baileys);
describe("reading a Baileys message", () => {
it("reads the text of a plain message, and of one that was written as extended text", async () => {
expect(await read({ key: { remoteJid: "[email protected]", id: "A1" }, message: { conversation: "hello" }, pushName: "Ann" })).toEqual({
id: "A1",
chat: "[email protected]",
fromMe: false,
senderName: "Ann",
content: { kind: "text", text: "hello" },
});
expect((await read({ key: { remoteJid: "[email protected]", id: "A2" }, message: { extendedTextMessage: { text: "a link https://example.com" } } }))?.content).toEqual({
kind: "text",
text: "a link https://example.com",
});
});
it("keeps the other address of a chat that Baileys found: a LID chat with the phone beside it", async () => {
const incoming = await read({
key: { remoteJid: "99887766554433@lid", remoteJidAlt: "[email protected]", addressingMode: "lid", id: "A3" },
message: { conversation: "from a LID" },
});
expect(incoming).toMatchObject({ chat: "99887766554433@lid", chatAlt: "[email protected]", content: { kind: "text", text: "from a LID" } });
});
it("reads through the wrappers WhatsApp puts around a message: disappearing, view once", async () => {
expect((await read({ key: { remoteJid: "[email protected]", id: "B1" }, message: { ephemeralMessage: { message: { conversation: "this will disappear" } } } }))?.content).toEqual({
kind: "text",
text: "this will disappear",
});
expect((await read({ key: { remoteJid: "[email protected]", id: "B2" }, message: { viewOnceMessage: { message: { imageMessage: { mimetype: "image/jpeg" } } } } }))?.content).toEqual({ kind: "attachment" });
});
it("calls media, contacts and places attachments, and reactions and protocol messages nothing", async () => {
const chat = { remoteJid: "[email protected]" };
const kinds = async (message: Record<string, unknown>, id: string) => (await read({ key: { ...chat, id }, message }))?.content.kind;
expect(await kinds({ imageMessage: { mimetype: "image/jpeg" } }, "C1")).toBe("attachment");
expect(await kinds({ audioMessage: { mimetype: "audio/ogg", ptt: true } }, "C2")).toBe("attachment");
expect(await kinds({ documentMessage: { fileName: "a.pdf" } }, "C3")).toBe("attachment");
expect(await kinds({ stickerMessage: {} }, "C4")).toBe("attachment");
expect(await kinds({ locationMessage: { degreesLatitude: 1, degreesLongitude: 2 } }, "C5")).toBe("attachment");
expect(await kinds({ contactMessage: { displayName: "Bob" } }, "C6")).toBe("attachment");
expect(await kinds({ reactionMessage: { text: "ok" } }, "C7")).toBe("none");
expect(await kinds({ protocolMessage: { type: 0 } }, "C8")).toBe("none");
expect(await kinds({ senderKeyDistributionMessage: { groupId: "g" } }, "C9")).toBe("none");
});
it("marks what the account wrote itself, and skips a message that has no id or no chat: nobody could answer it", async () => {
expect((await read({ key: { remoteJid: "[email protected]", id: "D1", fromMe: true }, message: { conversation: "mine" } }))?.fromMe).toBe(true);
const lib = await baileys;
expect(toIncoming({ key: { remoteJid: "[email protected]" }, message: { conversation: "no id" } } as WAMessage, lib)).toBeNull();
expect(toIncoming({ key: { id: "E1" }, message: { conversation: "no chat" } } as WAMessage, lib)).toBeNull();
expect(toIncoming({ key: { remoteJid: "[email protected]", id: "E2" } } as WAMessage, lib)?.content).toEqual({ kind: "none" });
});
});
describe("the connector on a socket that cannot connect", () => {
it("loads Baileys, opens the state of a device that is not linked, reports the connection that fails as an end, and closes without writing after", async () => {
const stored = new Map<string, string>();
const secrets = {
get: async (_scope: string, name: string) => stored.get(name) ?? null,
putAll: async (_scope: string, entries: Readonly<Record<string, string>>) => void Object.entries(entries).forEach(([name, value]) => stored.set(name, value)),
};
const ends: WhatsAppEnd[] = [];
const codes: string[] = [];
const events: WhatsAppEvents = { code: (c) => codes.push(c), open: () => {}, message: () => {}, end: (e) => ends.push(e) };
const connector = new BaileysConnector("dot_glue", secrets, { clientDir: CLIENT_DIR, waWebSocketUrl: "ws://127.0.0.1:1/" });
const connection = await connector.connect(events);
const deadline = Date.now() + 15_000;
while (ends.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
expect(ends[0]).toMatchObject({ reason: expect.stringMatching(/^(lost|code_expired)$/) });
expect(codes).toEqual([]);
await connection.close();
const writtenBefore = stored.size;
await new Promise((resolve) => setTimeout(resolve, 100));
expect(stored.size).toBe(writtenBefore);
// Nothing is sent to a connection that is not up.
await expect(connection.sendText("[email protected]", "hello")).rejects.toThrow();
}, 30_000);
});
@@ -0,0 +1,28 @@
/**
* What Baileys does with the text the glue hands it, against the real library. Needs the opt-in client installed
* (`npm run whatsapp:install`): this suite is `npm run test:whatsapp`.
*/
import { describe, expect, it } from "vitest";
import { textContent } from "../src/whatsapp-baileys/baileys.js";
import { baileys, type Baileys } from "./client.js";
describe("what is sent", () => {
/** Baileys' own content builder, with a `getUrlInfo` that records the links it was asked to preview (the real one fetches them, from this machine). */
async function previewed(content: { text: string; linkPreview?: null }): Promise<string[]> {
const lib: Baileys = await baileys;
const fetched: string[] = [];
const getUrlInfo = async (url: string) => {
fetched.push(url);
return undefined;
};
await lib.generateWAMessageContent(content, { getUrlInfo, upload: async () => ({}) as never } as never);
return fetched;
}
it("never has Baileys fetch a link of a text: an approval prompt or a Dot's message is not requested from here", async () => {
const text = "The Dot asks to open https://example.com/search?q=the+secret";
expect(await previewed(textContent(text))).toEqual([]);
// Without the setting, the same text is fetched by the library: the check above can fail.
expect(await previewed({ text })).toEqual(["https://example.com/search?q=the+secret"]);
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Compile-time proof that the real Baileys module has the shape the adapter wrote down in `client.ts`: the typecheck
* of `tsconfig.whatsapp.json` (`npm run typecheck:whatsapp`, after `npm run whatsapp:install`) fails on any line here
* that stops being true, for example when an upgrade of the pinned release renames something the adapter uses. It is
* not a test run and nothing imports it.
*/
import type * as Baileys from "baileys";
import type { AuthenticationCreds, AuthenticationState, SignalDataTypeMap } from "baileys";
import { AuthStore, GROUP_SECRETS } from "../src/whatsapp-baileys/auth-state.js";
import { socketConfig } from "../src/whatsapp-baileys/baileys.js";
import type { SignalKeyGroup, WhatsAppClient, WhatsAppSocketConfig } from "../src/whatsapp-baileys/client.js";
/** One direction of assignability, strictly: `true` when a `From` can be used where a `To` is wanted. */
type Fits<From, To> = [From] extends [To] ? true : false;
/**
* The module is a `WhatsAppClient`, with the library's own credentials and key data in place of the adapter's loose view.
* Its methods are compared both ways (they are methods), so what the adapter passes in is proved below, one way.
*/
export const moduleFits: WhatsAppClient<AuthenticationCreds, SignalDataTypeMap> = {} as typeof Baileys;
/** The socket it makes is a `WhatsAppSocket`. */
export const socketFits: ReturnType<WhatsAppClient["makeWASocket"]> = {} as ReturnType<typeof Baileys.makeWASocket>;
/** The options the adapter passes to `makeWASocket` are, for each one, an option of the library with a type the library takes... */
type LibraryOptions = Parameters<typeof Baileys.makeWASocket>[0];
export const optionsFit: Fits<ReturnType<typeof socketConfig<AuthenticationState>>, LibraryOptions> = true;
/** ...and none is a name the library does not have (a renamed option would otherwise be passed and ignored without a word). */
export const noUnknownOption: Exclude<keyof WhatsAppSocketConfig, keyof LibraryOptions> extends never ? true : false = true;
/** The auth state the adapter builds is one the library takes: its credentials are the ones `initAuthCreds` makes, and its key store answers as the library's does. */
export const authFits: Fits<AuthStore<AuthenticationCreds, SignalDataTypeMap>["state"], AuthenticationState> = true;
/** The groups of keys are exactly the library's: neither a missing nor an extra one. */
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
export const sameGroups: Same<SignalKeyGroup, keyof SignalDataTypeMap> = true;
export const everyGroupHasASecret: Record<keyof SignalDataTypeMap, string> = GROUP_SECRETS;
+5 -120
View File
@@ -1,104 +1,14 @@
/**
* The Baileys glue. WhatsApp itself cannot be reached from a test, so what is proved here is what the glue
* decides on its own: how a Baileys message is read (`toIncoming`, over messages built with Baileys' own
* protobuf classes and passed through its encoder and decoder, so they are the library's objects and not
* hand-made look-alikes), how a closed connection is understood (`endOf`), and that the connector survives
* a socket that cannot connect. The real network is not tested in CI.
* What the Baileys glue decides on its own, without the library: how a closed connection is understood (`endOf`) and
* how WhatsApp addresses are told apart. The parts that need the real library (how a message is read, the connector
* on a socket that cannot connect, that Baileys fetches no link of a text) are in packages/channels/test-optin, run by
* `npm run test:whatsapp`.
*/
import type { WAMessage } from "baileys";
import { describe, expect, it } from "vitest";
import { BaileysConnection, BaileysConnector, endOf, textContent, toIncoming } from "../src/whatsapp-baileys/baileys.js";
import { BaileysConnection, endOf } from "../src/whatsapp-baileys/baileys.js";
import { isDirectJid, lidOf, phoneOf } from "../src/whatsapp-baileys/jid.js";
import type { WhatsAppEnd, WhatsAppEvents, WhatsAppIncoming } from "../src/whatsapp-baileys/port.js";
type Baileys = typeof import("baileys");
const baileys = import("baileys");
/** A message as Baileys hands it over: the protobuf part through the real encoder and decoder, the address extras Baileys adds to the key on top. */
async function received(spec: { key: Record<string, unknown>; message?: Record<string, unknown>; pushName?: string }): Promise<WAMessage> {
const lib: Baileys = await baileys;
const info = lib.proto.WebMessageInfo.create({ key: { remoteJid: spec.key.remoteJid as string, id: spec.key.id as string, fromMe: spec.key.fromMe === true }, message: spec.message, pushName: spec.pushName });
const decoded = lib.proto.WebMessageInfo.decode(lib.proto.WebMessageInfo.encode(info).finish()) as WAMessage;
Object.assign(decoded.key, spec.key);
return decoded;
}
const read = async (spec: Parameters<typeof received>[0]): Promise<WhatsAppIncoming | null> => toIncoming(await received(spec), await baileys);
describe("reading a Baileys message", () => {
it("reads the text of a plain message, and of one that was written as extended text", async () => {
expect(await read({ key: { remoteJid: "[email protected]", id: "A1" }, message: { conversation: "hello" }, pushName: "Ann" })).toEqual({
id: "A1",
chat: "[email protected]",
fromMe: false,
senderName: "Ann",
content: { kind: "text", text: "hello" },
});
expect((await read({ key: { remoteJid: "[email protected]", id: "A2" }, message: { extendedTextMessage: { text: "a link https://example.com" } } }))?.content).toEqual({
kind: "text",
text: "a link https://example.com",
});
});
it("keeps the other address of a chat that Baileys found: a LID chat with the phone beside it", async () => {
const incoming = await read({
key: { remoteJid: "99887766554433@lid", remoteJidAlt: "[email protected]", addressingMode: "lid", id: "A3" },
message: { conversation: "from a LID" },
});
expect(incoming).toMatchObject({ chat: "99887766554433@lid", chatAlt: "[email protected]", content: { kind: "text", text: "from a LID" } });
});
it("reads through the wrappers WhatsApp puts around a message: disappearing, view once", async () => {
expect((await read({ key: { remoteJid: "[email protected]", id: "B1" }, message: { ephemeralMessage: { message: { conversation: "this will disappear" } } } }))?.content).toEqual({
kind: "text",
text: "this will disappear",
});
expect((await read({ key: { remoteJid: "[email protected]", id: "B2" }, message: { viewOnceMessage: { message: { imageMessage: { mimetype: "image/jpeg" } } } } }))?.content).toEqual({ kind: "attachment" });
});
it("calls media, contacts and places attachments, and reactions and protocol messages nothing", async () => {
const chat = { remoteJid: "[email protected]" };
const kinds = async (message: Record<string, unknown>, id: string) => (await read({ key: { ...chat, id }, message }))?.content.kind;
expect(await kinds({ imageMessage: { mimetype: "image/jpeg" } }, "C1")).toBe("attachment");
expect(await kinds({ audioMessage: { mimetype: "audio/ogg", ptt: true } }, "C2")).toBe("attachment");
expect(await kinds({ documentMessage: { fileName: "a.pdf" } }, "C3")).toBe("attachment");
expect(await kinds({ stickerMessage: {} }, "C4")).toBe("attachment");
expect(await kinds({ locationMessage: { degreesLatitude: 1, degreesLongitude: 2 } }, "C5")).toBe("attachment");
expect(await kinds({ contactMessage: { displayName: "Bob" } }, "C6")).toBe("attachment");
expect(await kinds({ reactionMessage: { text: "ok" } }, "C7")).toBe("none");
expect(await kinds({ protocolMessage: { type: 0 } }, "C8")).toBe("none");
expect(await kinds({ senderKeyDistributionMessage: { groupId: "g" } }, "C9")).toBe("none");
});
it("marks what the account wrote itself, and skips a message that has no id or no chat: nobody could answer it", async () => {
expect((await read({ key: { remoteJid: "[email protected]", id: "D1", fromMe: true }, message: { conversation: "mine" } }))?.fromMe).toBe(true);
const lib = await baileys;
expect(toIncoming({ key: { remoteJid: "[email protected]" }, message: { conversation: "no id" } } as WAMessage, lib)).toBeNull();
expect(toIncoming({ key: { id: "E1" }, message: { conversation: "no chat" } } as WAMessage, lib)).toBeNull();
expect(toIncoming({ key: { remoteJid: "[email protected]", id: "E2" } } as WAMessage, lib)?.content).toEqual({ kind: "none" });
});
});
describe("what is sent", () => {
/** Baileys' own content builder, with a `getUrlInfo` that records the links it was asked to preview (the real one fetches them, from this machine). */
async function previewed(content: { text: string; linkPreview?: null }): Promise<string[]> {
const lib: Baileys = await baileys;
const fetched: string[] = [];
const getUrlInfo = async (url: string) => {
fetched.push(url);
return undefined;
};
await lib.generateWAMessageContent(content, { getUrlInfo, upload: async () => ({}) as never } as never);
return fetched;
}
it("never has Baileys fetch a link of a text: an approval prompt or a Dot's message is not requested from here", async () => {
const text = "The Dot asks to open https://example.com/search?q=the+secret";
expect(await previewed(textContent(text))).toEqual([]);
// Without the setting, the same text is fetched by the library: the check above can fail.
expect(await previewed({ text })).toEqual(["https://example.com/search?q=the+secret"]);
});
it("is sent, and edited, with the preview off", async () => {
const calls: unknown[][] = [];
const socket = {
@@ -160,28 +70,3 @@ describe("addresses", () => {
expect(isDirectJid("99887766554433@lid")).toBe(true);
});
});
describe("the connector on a socket that cannot connect", () => {
it("loads Baileys, opens the state of a device that is not linked, reports the connection that fails as an end, and closes without writing after", async () => {
const stored = new Map<string, string>();
const secrets = {
get: async (_scope: string, name: string) => stored.get(name) ?? null,
putAll: async (_scope: string, entries: Readonly<Record<string, string>>) => void Object.entries(entries).forEach(([name, value]) => stored.set(name, value)),
};
const ends: WhatsAppEnd[] = [];
const codes: string[] = [];
const events: WhatsAppEvents = { code: (c) => codes.push(c), open: () => {}, message: () => {}, end: (e) => ends.push(e) };
const connector = new BaileysConnector("dot_glue", secrets, { waWebSocketUrl: "ws://127.0.0.1:1/" });
const connection = await connector.connect(events);
const deadline = Date.now() + 15_000;
while (ends.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
expect(ends[0]).toMatchObject({ reason: expect.stringMatching(/^(lost|code_expired)$/) });
expect(codes).toEqual([]);
await connection.close();
const writtenBefore = stored.size;
await new Promise((resolve) => setTimeout(resolve, 100));
expect(stored.size).toBe(writtenBefore);
// Nothing is sent to a connection that is not up.
await expect(connection.sendText("[email protected]", "hello")).rejects.toThrow();
}, 30_000);
});
+12 -1
View File
@@ -18,6 +18,17 @@ const ANN_LID = "99887766554433@lid";
const BEN = "[email protected]";
const NUMBER = "15550001111";
/**
* What the auth state needs of the library, as JSON: this test is about what the hub deletes with a Dot and about a
* write that must not come back, not about how Baileys serializes its keys (the real library is in
* test-optin/auth-state.test.ts).
*/
const SESSION_LIB: BaileysAuthLib = {
initAuthCreds: () => ({}),
BufferJSON: { replacer: (_key, value) => value, reviver: (_key, value) => value },
proto: { Message: { AppStateSyncKeyData: { fromObject: (object) => object } } },
};
describe.each(testAdapters())("the hub with the WhatsApp channel, with the real Scheduler and a fake guest (%s)", { timeout: 60_000 }, (kind) => {
let t: TestDatabase;
let db: Database;
@@ -272,7 +283,7 @@ describe.each(testAdapters())("the hub with the WhatsApp channel, with the real
const { dot, connection, type } = await linked(w);
// The session the real adapter would hold open: its keys are written as they change, until the connection is closed.
const binding = (await db.channels.listBindings(dot.id))[0]!;
const auth = await AuthStore.open(dot.id, new BindingSecrets(db, binding.id), (await import("baileys")) as unknown as BaileysAuthLib);
const auth = await AuthStore.open(dot.id, new BindingSecrets(db, binding.id), SESSION_LIB);
await auth.state.keys.set({ session: { a: Buffer.from("ratchet") } });
await auth.saveCreds();
for (const name of ["whatsapp_creds", "whatsapp_keys_session"]) expect(await db.secrets.get(dot.id, name)).not.toBeNull();
@@ -0,0 +1,120 @@
/**
* The WhatsApp client is an opt-in install (it depends on libsignal, GPL-3.0): the adapter loads it from its own
* folder only when it is there, and otherwise says how to enable it. The library itself is not needed here: a folder
* that holds a stand-in module of the same name is enough to prove what the loader does. The real library is in
* test-optin/, run by `npm run test:whatsapp`.
*/
import type { ChannelBindingRecord } from "@invisible-dots/database";
import { ENV } from "@invisible-dots/shared";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { WHATSAPP_INSTALL_COMMAND, WhatsAppChannelType, WhatsAppClientMissingError, WhatsAppClientVersionError, loadWhatsAppClient, whatsappClientDir, whatsappClientProblem } from "../src/index.js";
import { BaileysConnector } from "../src/whatsapp-baileys/baileys.js";
import type { WhatsAppEvents } from "../src/whatsapp-baileys/port.js";
const folders: string[] = [];
afterEach(async () => {
for (const folder of folders.splice(0)) await rm(folder, { recursive: true, force: true });
});
const emptyFolder = async () => {
const folder = await mkdtemp(join(tmpdir(), "idots-wa-client-"));
folders.push(folder);
return folder;
};
/** What `npm run whatsapp:install` leaves in the client folder, with a stand-in for the library: a package called baileys. `pinned` is the version the folder's package.json declares, `installed` the one in node_modules. */
async function install(folder: string, { pinned = "0.0.0-stand-in", installed = "0.0.0-stand-in" }: { pinned?: string | null; installed?: string } = {}): Promise<void> {
await writeFile(join(folder, "package.json"), JSON.stringify({ name: "stand-in", private: true, type: "module", dependencies: pinned === null ? {} : { baileys: pinned } }));
const lib = join(folder, "node_modules", "baileys");
await mkdir(lib, { recursive: true });
await writeFile(join(lib, "package.json"), JSON.stringify({ name: "baileys", version: installed, type: "module", main: "index.js" }));
await writeFile(join(lib, "index.js"), "export const marker = 'stand-in';\nexport function initAuthCreds() { return { fresh: true }; }\n");
}
describe("the opt-in WhatsApp client", () => {
it("is looked for under optional/whatsapp of the repository", () => {
expect(whatsappClientDir("/repo")).toBe(join("/repo", "optional", "whatsapp"));
});
it("is not installed in an empty folder, and loading it fails with the one command that installs it and the variable that turns WhatsApp on", async () => {
const folder = await emptyFolder();
expect(whatsappClientProblem(folder)).toBeInstanceOf(WhatsAppClientMissingError);
const error = await loadWhatsAppClient(folder).catch((e: unknown) => e);
expect(error).toBeInstanceOf(WhatsAppClientMissingError);
const message = (error as Error).message;
expect(WHATSAPP_INSTALL_COMMAND).toBe("npm run whatsapp:install");
expect(message).toContain("`npm run whatsapp:install`");
expect(message).toContain(`${ENV.WHATSAPP}=1`);
expect(message).toContain("GPL-3.0");
});
it("is loaded from the folder it is installed in, by path, and a folder that gets it after a failed try finds it the next time", async () => {
const folder = await emptyFolder();
await expect(loadWhatsAppClient(folder)).rejects.toBeInstanceOf(WhatsAppClientMissingError);
await install(folder);
expect(whatsappClientProblem(folder)).toBeNull();
const lib = (await loadWhatsAppClient(folder)) as unknown as { marker: string; initAuthCreds(): unknown };
expect(lib.marker).toBe("stand-in");
expect(lib.initAuthCreds()).toEqual({ fresh: true });
});
it("is not found in a parent folder: only the folder it was installed into counts", async () => {
const parent = await emptyFolder();
await install(parent);
const child = join(parent, "inner");
await mkdir(child);
expect(whatsappClientProblem(child)).toBeInstanceOf(WhatsAppClientMissingError);
await expect(loadWhatsAppClient(child)).rejects.toBeInstanceOf(WhatsAppClientMissingError);
});
it("is not loaded when the installed release is not the pinned one: a pull that moved the pin leaves the old release in node_modules, and nothing runs until the install is made again", async () => {
const folder = await emptyFolder();
await install(folder, { pinned: "7.0.0-rc15", installed: "7.0.0-rc14" });
const problem = whatsappClientProblem(folder);
expect(problem).toBeInstanceOf(WhatsAppClientVersionError);
expect(problem).toMatchObject({ pinned: "7.0.0-rc15", installed: "7.0.0-rc14" });
const error = await loadWhatsAppClient(folder).catch((e: unknown) => e);
expect(error).toBeInstanceOf(WhatsAppClientVersionError);
const message = (error as Error).message;
expect(message).toContain("7.0.0-rc14");
expect(message).toContain("7.0.0-rc15");
expect(message).toContain("`npm run whatsapp:install`");
expect(message).toContain(`${ENV.WHATSAPP}=1`);
// The install is made again, and the same folder loads.
await install(folder, { pinned: "7.0.0-rc15", installed: "7.0.0-rc15" });
expect(whatsappClientProblem(folder)).toBeNull();
await expect(loadWhatsAppClient(folder)).resolves.toBeDefined();
});
it("is not loaded when the folder pins no release, or the installed package says no version: what cannot be compared is not trusted", async () => {
const folder = await emptyFolder();
await install(folder, { pinned: null });
expect(whatsappClientProblem(folder)).toMatchObject({ name: "WhatsAppClientVersionError", pinned: null, installed: "0.0.0-stand-in" });
await expect(loadWhatsAppClient(folder)).rejects.toBeInstanceOf(WhatsAppClientVersionError);
await install(folder);
await writeFile(join(folder, "node_modules", "baileys", "package.json"), JSON.stringify({ name: "baileys", type: "module", main: "index.js" }));
expect(whatsappClientProblem(folder)).toMatchObject({ name: "WhatsAppClientVersionError", pinned: "0.0.0-stand-in", installed: null });
});
it("makes a connector that fails to connect with those words, before it reads or writes a single secret", async () => {
const folder = await emptyFolder();
const touched: string[] = [];
const secrets = {
get: async (_scope: string, name: string) => (touched.push(name), null),
putAll: async (_scope: string, entries: Readonly<Record<string, string>>) => void touched.push(...Object.keys(entries)),
};
const events: WhatsAppEvents = { code: () => {}, open: () => {}, message: () => {}, end: () => {} };
await expect(new BaileysConnector("dot_1", secrets, { clientDir: folder }).connect(events)).rejects.toThrow(/npm run whatsapp:install/);
expect(touched).toEqual([]);
});
it("makes a channel whose run says the same, so the status of a Dot's WhatsApp channel names the command", async () => {
const folder = await emptyFolder();
const binding = { id: "chb_1", dot_id: "dot_1", kind: "whatsapp", enabled: true, settings: {}, status: "connecting", status_detail: null, account: null, event_cursor: 0, created_at: "now" } as ChannelBindingRecord;
const channel = await new WhatsAppChannelType({ baileys: { clientDir: folder } }).create(binding, { get: async () => null, putAll: async () => {} });
const sink = { inbound: async () => {}, pairing: async () => false, approval: async () => "", status: () => {}, linkCode: () => {} };
await expect(channel.run(sink, new AbortController().signal)).rejects.toThrow(/npm run whatsapp:install/);
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* The WhatsApp channel on its own, over the fake connection: who a message is from, what it is, what is never
* answered, how the Dot's words are sent and how a connection that ends is understood. The hub's policies on
* top of it are in hub-whatsapp.test.ts; the Baileys glue is in baileys.test.ts. Nothing here touches a
* top of it are in hub-whatsapp.test.ts; the Baileys glue is in baileys.test.ts and test-optin/. Nothing here touches a
* network: WhatsApp cannot be faked, so the channel runs on `FakeWhatsAppConnector`.
*/
import type { ChannelBindingRecord } from "@invisible-dots/database";
@@ -310,7 +310,7 @@ describe("how a connection ends", () => {
});
describe("the channel type", () => {
const type = new WhatsAppChannelType();
const type = new WhatsAppChannelType({ connector: () => new FakeWhatsAppConnector() });
it("is a scanned channel with no credential a person gives, and names every secret its linked device keeps", () => {
expect(type.kind).toBe("whatsapp");
@@ -0,0 +1,62 @@
/**
* Nothing under the GPL is installed by default. `npm ci` installs what package-lock.json says, so the lock is
* scanned: no package of it may need a GPL or AGPL license (the WhatsApp client's dependency libsignal is GPL-3.0 and
* lives in its own opt-in folder, optional/whatsapp), and a package whose license the lock does not state is not
* allowed to hide one. LGPL is not GPL; the few LGPL packages that remain (the image library Next.js may install, as
* prebuilt binaries) are named here, and THIRD_PARTY_NOTICES.md says so.
*/
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { lockedPackages, mentionsLgpl, needsStrongCopyleft, packageName } from "./lock-licenses.js";
const repo = resolve(fileURLToPath(new URL(".", import.meta.url)), "../..");
const lockOf = (path: string) => lockedPackages(JSON.parse(readFileSync(join(repo, path), "utf8")));
const rootLock = lockOf("package-lock.json");
const optInLock = lockOf("optional/whatsapp/package-lock.json");
describe("the license scan of a lock file", () => {
it("flags a package that needs GPL or AGPL, in any version and wording, and lets an alternative that is not GPL through", () => {
for (const gpl of ["GPL-3.0", "GPL-2.0-only", "GPL-3.0-or-later", "AGPL-3.0", "MIT AND GPL-2.0-only", "(GPL-2.0 OR GPL-3.0)", "GPL-2.0 WITH Classpath-exception-2.0"]) {
expect(needsStrongCopyleft(gpl), gpl).toBe(true);
}
for (const fine of ["MIT", "(MIT OR GPL-3.0)", "(BSD-2-Clause OR GPL-2.0)", "LGPL-3.0-or-later", "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "ISC"]) {
expect(needsStrongCopyleft(fine), fine).toBe(false);
}
expect(mentionsLgpl("Apache-2.0 AND LGPL-3.0-or-later")).toBe(true);
expect(mentionsLgpl("GPL-3.0")).toBe(false);
});
it("would catch libsignal: the opt-in lock holds a GPL-3.0 package that the scan flags", () => {
const flagged = optInLock.filter((p) => p.license !== undefined && needsStrongCopyleft(p.license)).map((p) => packageName(p.path));
expect(flagged).toEqual(["libsignal"]);
});
});
describe("a default install (npm ci on package-lock.json)", () => {
it("installs no package that needs a GPL or AGPL license", () => {
const flagged = rootLock.filter((p) => p.license !== undefined && needsStrongCopyleft(p.license)).map((p) => `${packageName(p.path)} ${p.version}: ${p.license}`);
expect(flagged).toEqual([]);
});
it("states a license for every package it installs, so none can hide a GPL one (only the workspaces' own links have none)", () => {
const unstated = rootLock.filter((p) => p.license === undefined && p.link !== true).map((p) => p.path);
expect(unstated).toEqual([]);
for (const p of rootLock.filter((p) => p.link)) expect(p.path, "a link is one of this repository's own workspaces").toMatch(/^node_modules\/@invisible-dots\//);
});
it("installs LGPL packages only among the prebuilt image libraries Next.js may fetch, which the notices name", () => {
const lgpl = rootLock.filter((p) => p.license !== undefined && mentionsLgpl(p.license)).map((p) => packageName(p.path));
expect(lgpl.length).toBeGreaterThan(0);
for (const name of lgpl) expect(name, name).toMatch(/^@img\/sharp(-libvips)?-/);
const notices = readFileSync(join(repo, "THIRD_PARTY_NOTICES.md"), "utf8");
expect(notices).toContain("LGPL-3.0");
expect(notices).toContain("@img/sharp");
});
it("holds none of the WhatsApp client or of what only it needs", () => {
const names = new Set(rootLock.map((p) => packageName(p.path)));
for (const name of ["baileys", "libsignal", "whatsapp-rust-bridge", "curve25519-js"]) expect(names.has(name), name).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
/** What a lock file says about the license of each package it installs. Shared by the checks that read package-lock.json files. */
export interface LockedPackage {
/** The lock's key, such as `node_modules/baileys`. */
path: string;
version?: string;
license?: string;
link?: boolean;
resolved?: string;
integrity?: string;
}
/** Every package of an npm lock file (lockfileVersion 3), the root project left out. */
export function lockedPackages(lock: { packages: Record<string, Omit<LockedPackage, "path">> }): LockedPackage[] {
return Object.entries(lock.packages)
.filter(([path]) => path !== "")
.map(([path, entry]) => ({ path, ...entry }));
}
/** The name a package is installed under: `node_modules/a/node_modules/b` is `b`. */
export function packageName(path: string): string {
return path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
}
/** Whether one license identifier is GPL or AGPL (any version); LGPL is a different license and is not matched. */
const isStrongCopyleft = (id: string) => /^A?GPL-/i.test(id.trim());
/**
* Whether installing under this SPDX expression would put GPL or AGPL code on the machine: true for `GPL-3.0` and
* `MIT AND GPL-2.0-only`; false for `(MIT OR GPL-3.0)`, which can be taken as MIT, and for `LGPL-3.0-or-later`.
*/
export function needsStrongCopyleft(expression: string): boolean {
const alternatives = expression
.replace(/[()]/g, " ")
.split(/\s+OR\s+/)
.map((alternative) => alternative.split(/\s+AND\s+/));
return alternatives.every((conjunction) => conjunction.some((id) => isStrongCopyleft(id.replace(/\s+WITH\s+.*$/, ""))));
}
/** Whether the expression names LGPL anywhere. */
export const mentionsLgpl = (expression: string): boolean => /\bLGPL-/i.test(expression);
+2 -1
View File
@@ -116,9 +116,10 @@ describe("test-guard", () => {
for (const host of ["linux", "win32"]) expect(floors[suite]?.[host]?.min_passed, `${suite} ${host}`).toBeGreaterThan(0);
}
expect(floors.postgres?.linux?.min_passed).toBeGreaterThan(0);
for (const host of ["linux", "win32"]) expect(floors.whatsapp?.[host]?.min_passed, `whatsapp ${host}`).toBeGreaterThan(0);
expect(floors.pytest?.linux?.min_passed).toBeGreaterThan(0);
const workflow = readFileSync(join(repo, ".github", "workflows", "tests.yml"), "utf8");
for (const suite of ["vitest", "postgres", "go", "pytest", "playwright"]) expect(workflow).toMatch(new RegExp(`test-guard\\.mjs \\S+ --suite ${suite}\\b`));
for (const suite of ["vitest", "postgres", "whatsapp", "go", "pytest", "playwright"]) expect(workflow).toMatch(new RegExp(`test-guard\\.mjs \\S+ --suite ${suite}\\b`));
const hook = readFileSync(join(repo, ".githooks", "pre-push"), "utf8");
expect(hook).toContain("npm run typecheck");
expect(hook).toContain("node .github/scripts/test-guard.mjs tmp/vitest-report.json --suite vitest");
+50 -4
View File
@@ -1,19 +1,36 @@
/**
* The golden image carries the GeoIP database of daijro/geoip-all-in-one, which merges the free editions of
* several IP geolocation databases, and those ask to be credited. THIRD_PARTY_NOTICES.md must carry the credit
* lines that guest/image-builder/src/geoip-notices.ts holds (the same ones every golden manifest records), and
* must say which project the data comes from.
* several IP geolocation databases, each under its own data license, most asking to be credited.
* THIRD_PARTY_NOTICES.md must carry the licenses and credit lines that guest/image-builder/src/geoip-notices.ts holds
* (the same ones every golden manifest records), say which project the data comes from, and say that the image is
* built by the person on their own machine, so invisible_dots does not redistribute the data. The licenses named are
* the data sources', not the GPL-3.0 that GitHub shows on the merging project's repository, which is the license of
* its scripts.
*/
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { GEOIP_NOTICES } from "../../guest/image-builder/src/geoip-notices.js";
import { GEOIP_NOTICES, GEOIP_STATEMENT } from "../../guest/image-builder/src/geoip-notices.js";
const repo = resolve(fileURLToPath(new URL(".", import.meta.url)), "../..");
const notices = readFileSync(join(repo, "THIRD_PARTY_NOTICES.md"), "utf8").replace(/\r\n/g, "\n");
const pins = JSON.parse(readFileSync(join(repo, "guest", "image-builder", "pins.json"), "utf8")) as { geoip: { url: string } };
/** Text with its line breaks and runs of spaces as single spaces, to compare a paragraph however it is wrapped. */
const squash = (text: string) => text.replace(/\s+/g, " ");
/** The data license of each source that the pinned release's README lists (daijro/geoip-all-in-one at 2026.09.30), by the start of its name. */
const SOURCE_LICENSES: Record<string, string> = {
"IP2Location LITE": "CC BY-SA 4.0",
"MaxMind GeoLite2": "GeoLite2 End User License Agreement",
"DB-IP Lite": "CC BY 4.0",
"IPinfo free country database": "CC BY-SA 4.0",
"IPLocate.io free IP to Country database": "CC BY-SA 4.0",
"GeoFeed + Whois + ASN country database of tdulcet/ip-geolocation-dbs": "CC0 1.0",
"OpenStreetMap contributors": "ODbL 1.0",
};
/** The section of the notices that starts at `## GeoIP data`, up to the next second-level heading. */
function geoipSection(): string {
const start = notices.indexOf("\n## GeoIP data in the golden image\n");
@@ -30,6 +47,7 @@ describe("the notices of the GeoIP data in the golden image", () => {
expect(section).toContain("pins.json");
expect(section).toContain("`pinned.geoip`");
expect(section).toContain("`notices`");
expect(section).toContain("`notices_statement`");
});
it("carry the source, the license and the credit line of every source in the manifest's list, as written there", () => {
@@ -43,6 +61,34 @@ describe("the notices of the GeoIP data in the golden image", () => {
}
});
it("name the data license of every source of the pinned release, and none of them is the GPL of the merging project's code", () => {
expect(GEOIP_NOTICES).toHaveLength(Object.keys(SOURCE_LICENSES).length);
for (const [source, license] of Object.entries(SOURCE_LICENSES)) {
const notice = GEOIP_NOTICES.find((n) => n.source.startsWith(source));
expect(notice, source).toBeDefined();
expect(notice!.license, source).toBe(license);
}
for (const notice of GEOIP_NOTICES) {
expect(notice.license, notice.source).not.toMatch(/GPL/);
expect(notice.source, "the merging project is not a data source").not.toMatch(/^daijro\//);
}
expect(squash(geoipSection())).not.toMatch(/data[^.]*(is|are) (published|licensed|released) under the GPL/i);
});
it("say who builds the image and that this project does not redistribute the data, as the manifest's statement does", () => {
expect(GEOIP_STATEMENT).toContain("on their own machine");
expect(GEOIP_STATEMENT).toContain("invisible_dots does not publish or redistribute the image or the data");
expect(GEOIP_STATEMENT).toContain("license of its scripts");
expect(squash(geoipSection())).toContain(squash(GEOIP_STATEMENT));
});
it("say it again in the README, the image builder's README and the architecture document", () => {
for (const path of ["README.md", "guest/image-builder/README.md", "docs/architecture.md"]) {
const text = squash(readFileSync(join(repo, path), "utf8"));
expect(text, path).toContain("does not redistribute");
}
});
it("are listed among the exceptions to the repository's own license", () => {
const head = notices.slice(0, notices.indexOf("\n## "));
expect(head).toContain("GeoIP data");
+107 -37
View File
@@ -1,21 +1,36 @@
/**
* WhatsApp is an opt-in adapter over an unofficial client. These checks keep the three promises that make it
* opt-in: the client is pinned to one exact release (it is a release candidate and the protocol moves under it),
* only the one glue file knows it, and that file loads it when a connection opens, never when the server starts, so
* a server that never links WhatsApp never loads it.
* WhatsApp is an opt-in adapter over an unofficial client, and the client is an opt-in install: Baileys depends on
* libsignal, which is GPL-3.0, and nothing GPL is installed by default. These checks keep what makes that true: the
* client is declared by no workspace and is in no default lock; it lives in its own folder with its own lock file, at
* one exact version, every package with its integrity hash; one documented command installs it; and the one glue
* file that loads it does so by path, when a connection opens, so neither the bundle nor a server that never links
* WhatsApp holds or loads it.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { WHATSAPP_INSTALL_COMMAND, whatsappClientDir } from "../../packages/channels/src/whatsapp-baileys/client.js";
import { lockedPackages, packageName, type LockedPackage } from "./lock-licenses.js";
const repo = resolve(fileURLToPath(new URL(".", import.meta.url)), "../..");
const read = (path: string) => readFileSync(join(repo, path), "utf8");
interface Manifest {
private?: boolean;
workspaces?: string[];
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}
const manifest = (path: string) => JSON.parse(read(path)) as Manifest;
const CLIENT_FOLDER = "optional/whatsapp";
/** Every TypeScript source of the workspaces, tests and build output left out (the bundler script names the package too, on purpose). */
function sources(dir: string, found: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (["node_modules", "dist", ".next", "test", "docs_graphify", ".local"].includes(entry.name)) continue;
if (["node_modules", "dist", ".next", "test", "test-optin", "docs_graphify", ".local"].includes(entry.name)) continue;
const path = join(dir, entry.name);
if (entry.isDirectory()) sources(path, found);
else if (/\.(ts|tsx)$/.test(entry.name)) found.push(path);
@@ -23,49 +38,104 @@ function sources(dir: string, found: string[] = []): string[] {
return found;
}
describe("the WhatsApp client", () => {
it("is pinned to one exact version in the channels package and in the lock file", () => {
const declared = (JSON.parse(read("packages/channels/package.json")) as { dependencies: Record<string, string> }).dependencies.baileys;
expect(declared).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/);
const lock = JSON.parse(read("package-lock.json")) as { packages: Record<string, { version?: string }> };
expect(lock.packages["node_modules/baileys"]?.version).toBe(declared);
});
it("is declared by no other package: one owner of the dependency", () => {
describe("the WhatsApp client is not in the default install", () => {
it("is declared by no workspace and by no dependency list of the root: not regular, dev, optional or peer", () => {
const manifests = ["package.json", "guest/image-builder/package.json"];
for (const where of ["apps", "packages"]) {
for (const name of readdirSync(join(repo, where))) {
const manifest = join(repo, where, name, "package.json");
if (!existsSync(manifest) || where + "/" + name === "packages/channels") continue;
const parsed = JSON.parse(readFileSync(manifest, "utf8")) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
expect({ ...parsed.dependencies, ...parsed.devDependencies }, manifest).not.toHaveProperty("baileys");
}
for (const name of readdirSync(join(repo, where))) if (existsSync(join(repo, where, name, "package.json"))) manifests.push(`${where}/${name}/package.json`);
}
expect(manifests.length).toBeGreaterThan(10);
for (const path of manifests) {
const parsed = manifest(path);
for (const list of [parsed.dependencies, parsed.devDependencies, parsed.optionalDependencies, parsed.peerDependencies]) expect(list ?? {}, path).not.toHaveProperty("baileys");
}
});
it("is imported by the glue file alone, as types and by one dynamic import() when a connection opens", () => {
const importing: string[] = [];
it("is outside every workspace, so `npm ci` at the root never reads its folder", () => {
const patterns = manifest("package.json").workspaces ?? [];
expect(patterns.length).toBeGreaterThan(0);
for (const pattern of patterns) {
const matches = pattern.endsWith("/*") ? CLIENT_FOLDER.startsWith(pattern.slice(0, -1)) && CLIENT_FOLDER.split("/").length === pattern.split("/").length : pattern === CLIENT_FOLDER;
expect(matches, `workspace pattern ${pattern}`).toBe(false);
}
});
it("is in no package of the root lock file", () => {
const names = lockedPackages(JSON.parse(read("package-lock.json"))).map((p) => packageName(p.path));
expect(names).not.toContain("baileys");
expect(names).not.toContain("libsignal");
});
});
describe("the WhatsApp client is installed by one command, from its own pinned lock file", () => {
const folder = manifest(`${CLIENT_FOLDER}/package.json`);
const lock = JSON.parse(read(`${CLIENT_FOLDER}/package-lock.json`)) as { lockfileVersion: number; packages: Record<string, Omit<LockedPackage, "path"> & { dependencies?: Record<string, string> }> };
it("pins Baileys to one exact version, which the lock file holds at that version", () => {
expect(Object.keys(folder.dependencies ?? {})).toEqual(["baileys"]);
const declared = folder.dependencies!.baileys!;
expect(declared).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/);
expect(lock.lockfileVersion).toBe(3);
expect(lock.packages[""]?.dependencies?.baileys).toBe(declared);
expect(lock.packages["node_modules/baileys"]?.version).toBe(declared);
expect(folder.private).toBe(true);
});
it("has an integrity hash and a registry address for every package it installs", () => {
const packages = lockedPackages(lock);
expect(packages.length).toBeGreaterThan(20);
for (const p of packages) {
expect(p.integrity, p.path).toMatch(/^sha512-/);
expect(p.resolved, p.path).toMatch(/^https:\/\/registry\.npmjs\.org\//);
}
});
it("is installed by `npm run whatsapp:install`, a clean install of that lock file without install scripts", () => {
expect(WHATSAPP_INSTALL_COMMAND).toBe("npm run whatsapp:install");
expect(manifest("package.json").scripts?.["whatsapp:install"]).toBe(`npm ci --prefix ${CLIENT_FOLDER} --ignore-scripts --no-audit --no-fund`);
expect(whatsappClientDir("/repo")).toBe(join("/repo", ...CLIENT_FOLDER.split("/")));
});
it("is the command the README, the architecture document and the notices tell a person to run", () => {
for (const path of ["README.md", "docs/architecture.md", "THIRD_PARTY_NOTICES.md"]) expect(read(path), path).toContain("npm run whatsapp:install");
});
it("has its types checked and its tests run by commands of their own, which the default ones leave out", () => {
const scripts = manifest("package.json").scripts ?? {};
expect(scripts["typecheck:whatsapp"]).toContain("tsconfig.whatsapp.json");
expect(scripts["test:whatsapp"]).toContain("vitest.whatsapp.config.ts");
expect(read("vitest.config.ts")).not.toContain("test-optin");
expect((JSON.parse(read("tsconfig.json")) as { include: string[] }).include.join(" ")).not.toContain("test-optin");
});
});
describe("the adapter loads the client", () => {
it("by one path, in one file, and no other source names the package", () => {
const naming: string[] = [];
for (const file of [...sources(join(repo, "apps")), ...sources(join(repo, "packages"))]) {
const text = readFileSync(file, "utf8");
if (!/["']baileys["']/.test(text)) continue;
importing.push(relative(repo, file).split(sep).join("/"));
for (const line of text.split("\n")) {
if (!/from ["']baileys["']|import\(["']baileys["']\)/.test(line)) continue;
// A static import is allowed only when it is erased: `import type`.
if (/from ["']baileys["']/.test(line)) expect(line.trimStart(), `${file}: ${line}`).toMatch(/^import type /);
}
if (/["']baileys["']/.test(readFileSync(file, "utf8"))) naming.push(relative(repo, file).split(sep).join("/"));
}
expect(importing.sort()).toEqual(["packages/channels/src/whatsapp-baileys/auth-state.ts", "packages/channels/src/whatsapp-baileys/baileys.ts"]);
const glue = read("packages/channels/src/whatsapp-baileys/baileys.ts");
// One runtime import, in `connect`; the other mention is the type alias `typeof import("baileys")`, which is erased.
expect(glue.match(/await import\(["']baileys["']\)/g)).toHaveLength(1);
expect(naming).toEqual(["packages/channels/src/whatsapp-baileys/client.ts"]);
const loader = read("packages/channels/src/whatsapp-baileys/client.ts");
// One dynamic import, of a file URL; and the package name is only ever resolved from the client's folder.
expect(loader.match(/await import\(/g)).toHaveLength(1);
expect(loader).toContain("pathToFileURL(found.entry).href");
expect(loader).toContain('createRequire(join(dir, "package.json")).resolve("baileys")');
});
it("stays out of the command bundle, because its dependency libsignal is GPL-3.0, and the notices say so", () => {
expect(read("apps/cli/scripts/build.mjs")).toMatch(/external: \[[^\]]*"baileys"/);
it("is imported by no source of the workspaces, not even as types: they compile without the library", () => {
for (const file of [...sources(join(repo, "apps")), ...sources(join(repo, "packages"))]) {
expect(readFileSync(file, "utf8"), file).not.toMatch(/from ["']baileys["']|import\(["']baileys["']\)|require\(["']baileys["']\)/);
}
});
it("is not in the command bundle, because libsignal is GPL-3.0, and the notices say so", () => {
expect(read("apps/cli/scripts/build.mjs")).not.toMatch(/external: \[[^\]]*baileys/);
const notices = read("THIRD_PARTY_NOTICES.md");
expect(notices).toContain("libsignal");
expect(notices).toContain("GPL-3.0");
const lock = JSON.parse(read("package-lock.json")) as { packages: Record<string, { license?: string }> };
expect(notices).toContain("optional/whatsapp");
const lock = JSON.parse(read(`${CLIENT_FOLDER}/package-lock.json`)) as { packages: Record<string, { license?: string }> };
expect(lock.packages["node_modules/libsignal"]?.license).toBe("GPL-3.0");
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"paths": {
"baileys": ["./optional/whatsapp/node_modules/baileys/lib/index.d.ts"]
}
},
"include": ["packages/channels/src/**/*.ts", "packages/channels/test-optin/**/*.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
// The tests that need the real WhatsApp client (Baileys), which is not part of the default install because it depends
// on libsignal (GPL-3.0). Run `npm run whatsapp:install` first; `npm run test:whatsapp` runs them. The default suite
// (vitest.config.ts) never includes this folder.
export default defineConfig({
test: {
include: ["packages/channels/test-optin/**/*.test.ts"],
environment: "node",
// Same allowances as the default suite: a real test database boots in a hook.
hookTimeout: 60_000,
testTimeout: 30_000,
},
});