mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
channels: the typecheck with the WhatsApp client installed checks the socket options and the auth state, and the loader refuses a client that is not the pinned release (lic review)
What and why
Finding 1 (typecheck no longer checked the socket config or the auth state):
confirmed. makeWASocket took `object` and the credentials were `{ me?: unknown }`,
so a renamed or mistyped option (markOnlineOnConnect, syncFullHistory,
shouldIgnoreJid) and an auth state the library would reject both compiled. The
earlier commit's sentence that the new typecheck did what the old compile-time
check did overstated it. Fixed at the origin:
- client.ts declares WhatsAppSocketConfig, exactly the options the adapter
passes, and WhatsAppLogger; baileys.ts builds the object in one exported
function, socketConfig, whose declared return type makes a typo in the adapter a
compile error even without the library.
- The library's credentials and key data come in as type parameters (Creds,
KeyData) of WhatsAppAuthState, WhatsAppClient, BaileysAuthLib and AuthStore,
defaulting to the adapter's loose view, so the typecheck can instantiate them
with the library's own AuthenticationCreds and SignalDataTypeMap.
- test-optin/shape.ts asserts, one way and strictly (methods are compared both
ways, so the module-fits line alone could not): socketConfig's result is
assignable to the parameter of makeWASocket; no key of WhatsAppSocketConfig is
absent from the library's options; AuthStore's state is assignable to
AuthenticationState. Checked by mutation, each making npm run typecheck:whatsapp
fail: an option renamed, an option given the wrong type, a parameter type
changed, keys.get returning another type, creds typed loosely.
- The sentences in architecture.md (WhatsApp, licenses) and in client.ts now say
what is proved and what the method comparison does not.
Finding 2 (the loader did not check the installed release against the pin):
confirmed. entryOf only looked for node_modules/baileys, so after a pull that moved
the pin in optional/whatsapp/package.json the old release loaded silently.
Fixed: client.ts reads the one version package.json declares (it stays the one
owner of the pin) and the version in node_modules/baileys/package.json; any
difference, or either missing, is WhatsAppClientVersionError (names both versions
and `npm run whatsapp:install`). One function, whatsappClientProblem, replaces
whatsappClientInstalled and is what loadWhatsAppClient and the start.ts warning
both use, so the server warns at start with the exact reason. Tests that fail
without the check (verified by disabling it): whatsapp-client.test.ts (release
differs and is fixed by installing again; pin or installed version missing) and
start.test.ts (the warning names both releases). The stand-in client of the tests
now declares its pin like the real install does. tests/repo/whatsapp-optin.test.ts
follows the renamed variable in the loader's one import.
Tests: no test removed or weakened. whatsappClientInstalled's assertions became
whatsappClientProblem's (null or the error). Floors raised for 3 added vitest
tests: vitest linux 1196 to 1199, win32 1199 to 1202 (measured 1202). The
whatsapp suite is unchanged (15, shape.ts is a typecheck).
Verified: npm run typecheck, npm run typecheck:whatsapp, npm run test:whatsapp
(15), npx vitest run (1202 passed), go test ./... in guest/dot-agentd.
This commit is contained in:
1 parent
ee7b80cf76
commit
a39316c217
11 files changed
+226
-72
No files matched your search
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"//": "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 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": 1196, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
|
||||
"win32": { "min_passed": 1199, "allow_skipped": "" }
|
||||
"linux": { "min_passed": 1199, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
|
||||
"win32": { "min_passed": 1202, "allow_skipped": "" }
|
||||
},
|
||||
"postgres": {
|
||||
"linux": { "min_passed": 659, "allow_skipped": "", "require": { "\\bpg\\b": 264 } }
|
||||
|
||||
@@ -419,8 +419,9 @@ 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, linking says
|
||||
how to enable it ([THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)).
|
||||
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).
|
||||
|
||||
@@ -9,10 +9,9 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
ChannelHub,
|
||||
TelegramChannelType,
|
||||
WHATSAPP_ENABLE_HELP,
|
||||
WhatsAppChannelType,
|
||||
whatsappClientDir,
|
||||
whatsappClientInstalled,
|
||||
whatsappClientProblem,
|
||||
type ChannelType,
|
||||
} from "@invisible-dots/channels";
|
||||
import type { Database } from "@invisible-dots/database";
|
||||
@@ -129,9 +128,8 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
|
||||
});
|
||||
cleanup.push(() => scheduler.close());
|
||||
const clientDir = options.whatsappClientDir ?? DEFAULT_CLIENT_DIR;
|
||||
if (!options.channelTypes && env[ENV.WHATSAPP] === "1" && !whatsappClientInstalled(clientDir)) {
|
||||
logger.warn(`${ENV.WHATSAPP}=1 is set but the WhatsApp client is not installed: ${WHATSAPP_ENABLE_HELP}`);
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -240,11 +240,28 @@ describe("startServer on an empty INVISIBLE_DOTS_HOME", { timeout: 120_000 }, ()
|
||||
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"), "{}");
|
||||
await writeFile(join(client, "node_modules", "baileys", "package.json"), JSON.stringify({ name: "baileys", main: "index.js" }));
|
||||
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) };
|
||||
|
||||
+19
-8
@@ -2117,15 +2117,26 @@ hub.
|
||||
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 otherwise throws `WhatsAppClientMissingError`, whose
|
||||
message is 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 without the client, and the "it is off" answer
|
||||
of linking carries the same words (`WHATSAPP_ENABLE_HELP`). Nothing imports the
|
||||
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`) and compiles without it; `npm run typecheck:whatsapp` (a CI job,
|
||||
with the client installed) proves the real module has that shape, with the
|
||||
library's own key groups, and `npm run test:whatsapp` runs the tests that need
|
||||
(`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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* is now, never a step apart; and once the channel or its Dot is deleted nothing is written back.
|
||||
*/
|
||||
import type { ChannelSecrets } from "../channel.js";
|
||||
import type { SignalKeyGroup, WhatsAppAuthState, WhatsAppClient, WhatsAppCreds } from "./client.js";
|
||||
import type { SignalKeyData, SignalKeyGroup, WhatsAppAuthState, WhatsAppClient, WhatsAppCreds } from "./client.js";
|
||||
|
||||
export const WHATSAPP_CREDS_SECRET = "whatsapp_creds";
|
||||
|
||||
@@ -36,11 +36,12 @@ export const GROUP_SECRETS: Record<KeyGroup, string> = {
|
||||
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 loads it. */
|
||||
export type BaileysAuthLib = Pick<WhatsAppClient, "initAuthCreds" | "BufferJSON" | "proto">;
|
||||
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: WhatsAppAuthState;
|
||||
readonly state: WhatsAppAuthState<Creds, KeyData>;
|
||||
readonly #groups = new Map<KeyGroup, Record<string, unknown>>();
|
||||
readonly #dirty = new Set<KeyGroup>();
|
||||
#credsDirty = false;
|
||||
@@ -50,8 +51,8 @@ export class AuthStore {
|
||||
private constructor(
|
||||
private readonly dotId: string,
|
||||
private readonly secrets: ChannelSecrets,
|
||||
private readonly lib: BaileysAuthLib,
|
||||
creds: WhatsAppCreds,
|
||||
private readonly lib: BaileysAuthLib<Creds, KeyData>,
|
||||
creds: Creds,
|
||||
) {
|
||||
this.state = {
|
||||
creds,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import type { ChannelSecrets } from "../channel.js";
|
||||
import { AuthStore } from "./auth-state.js";
|
||||
import { loadWhatsAppClient, type WhatsAppClient, type WhatsAppMessage, type WhatsAppSocket } from "./client.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";
|
||||
|
||||
@@ -43,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;
|
||||
@@ -55,6 +55,23 @@ const SILENT_LOGGER = {
|
||||
error() {},
|
||||
};
|
||||
|
||||
/** 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: WhatsAppMessage, lib: Pick<WhatsAppClient, "normalizeMessageContent">): WhatsAppIncoming | null {
|
||||
const { id, remoteJid, remoteJidAlt, fromMe } = message.key;
|
||||
@@ -106,19 +123,7 @@ export class BaileysConnector implements WhatsAppConnector {
|
||||
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: string) => !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.
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
* 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.
|
||||
* 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 } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -34,6 +41,17 @@ export class WhatsAppClientMissingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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"
|
||||
@@ -52,15 +70,46 @@ 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: WhatsAppCreds;
|
||||
export interface WhatsAppAuthState<Creds extends WhatsAppCreds = WhatsAppCreds, KeyData extends SignalKeyData = SignalKeyData> {
|
||||
creds: Creds;
|
||||
keys: {
|
||||
get(type: SignalKeyGroup, ids: string[]): Promise<Record<string, unknown>>;
|
||||
set(data: Partial<Record<SignalKeyGroup, Record<string, unknown>>>): Promise<void>;
|
||||
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 };
|
||||
@@ -104,13 +153,17 @@ export interface WhatsAppSocket {
|
||||
end(error: Error | undefined): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** What the adapter uses of the Baileys module. Methods, not properties: their parameters are checked loosely, because the library's own are narrower than the adapter needs to promise. */
|
||||
export interface WhatsAppClient {
|
||||
makeWASocket(config: object): WhatsAppSocket;
|
||||
/**
|
||||
* 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(): WhatsAppCreds;
|
||||
initAuthCreds(): Creds;
|
||||
BufferJSON: { replacer(key: string, value: unknown): unknown; reviver(key: string, value: unknown): unknown };
|
||||
proto: { Message: { AppStateSyncKeyData: { fromObject(object: { [key: string]: 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. */
|
||||
@@ -123,14 +176,37 @@ function entryOf(dir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the client is installed under `dir`. */
|
||||
export function whatsappClientInstalled(dir: string): boolean {
|
||||
return entryOf(dir) !== 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the client from `dir`; throws `WhatsAppClientMissingError` when it is not installed there. */
|
||||
export async function loadWhatsAppClient(dir: string): Promise<WhatsAppClient> {
|
||||
/** 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) throw new WhatsAppClientMissingError();
|
||||
return (await import(pathToFileURL(entry).href)) as WhatsAppClient;
|
||||
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;
|
||||
}
|
||||
@@ -5,16 +5,32 @@
|
||||
* not a test run and nothing imports it.
|
||||
*/
|
||||
import type * as Baileys from "baileys";
|
||||
import type { SignalDataTypeMap } from "baileys";
|
||||
import { GROUP_SECRETS } from "../src/whatsapp-baileys/auth-state.js";
|
||||
import type { SignalKeyGroup, WhatsAppClient } from "../src/whatsapp-baileys/client.js";
|
||||
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";
|
||||
|
||||
/** The module is a `WhatsAppClient`. */
|
||||
export const moduleFits: WhatsAppClient = {} as typeof Baileys;
|
||||
/** 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;
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, loadWhatsAppClient, whatsappClientDir, whatsappClientInstalled } from "../src/index.js";
|
||||
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";
|
||||
|
||||
@@ -24,12 +24,12 @@ const emptyFolder = async () => {
|
||||
return folder;
|
||||
};
|
||||
|
||||
/** What `npm run whatsapp:install` leaves in the client folder, with a stand-in for the library: a package called baileys. */
|
||||
async function install(folder: string): Promise<void> {
|
||||
await writeFile(join(folder, "package.json"), JSON.stringify({ name: "stand-in", private: true, type: "module" }));
|
||||
/** 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: "0.0.0-stand-in", type: "module", main: "index.js" }));
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("the opt-in WhatsApp client", () => {
|
||||
|
||||
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(whatsappClientInstalled(folder)).toBe(false);
|
||||
expect(whatsappClientProblem(folder)).toBeInstanceOf(WhatsAppClientMissingError);
|
||||
const error = await loadWhatsAppClient(folder).catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(WhatsAppClientMissingError);
|
||||
const message = (error as Error).message;
|
||||
@@ -54,7 +54,7 @@ describe("the opt-in WhatsApp client", () => {
|
||||
const folder = await emptyFolder();
|
||||
await expect(loadWhatsAppClient(folder)).rejects.toBeInstanceOf(WhatsAppClientMissingError);
|
||||
await install(folder);
|
||||
expect(whatsappClientInstalled(folder)).toBe(true);
|
||||
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 });
|
||||
@@ -65,10 +65,39 @@ describe("the opt-in WhatsApp client", () => {
|
||||
await install(parent);
|
||||
const child = join(parent, "inner");
|
||||
await mkdir(child);
|
||||
expect(whatsappClientInstalled(child)).toBe(false);
|
||||
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[] = [];
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("the adapter loads the client", () => {
|
||||
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(entry).href");
|
||||
expect(loader).toContain("pathToFileURL(found.entry).href");
|
||||
expect(loader).toContain('createRequire(join(dir, "package.json")).resolve("baileys")');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user