wazap-mcp 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT.md +5 -3
- package/README.md +50 -20
- package/dist/account-cli.js +25 -4
- package/dist/account-hub.js +31 -0
- package/dist/account-resolve.js +13 -2
- package/dist/accounts.js +59 -1
- package/dist/cli.js +58 -48
- package/dist/config.js +1 -1
- package/dist/doctor.js +31 -10
- package/dist/index.js +9 -5
- package/dist/migrate.js +15 -1
- package/dist/settings.js +42 -16
- package/dist/webhook.js +25 -13
- package/dist/whatsapp.js +6 -2
- package/package.json +2 -2
package/AGENT.md
CHANGED
|
@@ -4,15 +4,17 @@ You are setting up WhatsApp access for the person you are working with. Use the
|
|
|
4
4
|
|
|
5
5
|
wazap keeps stdout for the MCP protocol and writes every human-readable line to stderr, so read stderr as well: run each command with `2>&1`.
|
|
6
6
|
|
|
7
|
-
1. Run `npx wazap-mcp status --json`. If `linked` is true, skip to step 5.
|
|
7
|
+
1. Run `npx wazap-mcp status --json`. `linked` is true when any configured account is linked; `accounts[]` lists each account's own credentials. If `linked` is true, skip to step 5. If it is false but `accounts[]` shows an account other than the default, link that account instead: pass `--account <id>` to `login` in step 3, or `account_id` to `link_account`.
|
|
8
8
|
2. Ask the person for their WhatsApp number in international format (e.g. +15550100) and whether the agent may send messages (default: no).
|
|
9
9
|
3. If you already have the `whatsapp` tools (the Claude Desktop bundle, or any client wazap is connected to), call `link_account` with the number. It returns the code within ~10 s and needs no terminal. Otherwise start the link IN THE BACKGROUND and keep reading its output:
|
|
10
10
|
`npx wazap-mcp login --phone <number> --no-writes --yes 2>&1` (or `--writes`).
|
|
11
11
|
Within ~10 s the output contains a line `pairing code: XXXX-XXXX`.
|
|
12
12
|
4. Show the person exactly: "On your phone: WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead → enter XXXX-XXXX". After `link_account`, call `get_status` every 10 seconds until it says `connected` (up to 3 minutes); after the command, keep reading the output until `Linked as …` and then `Synced N chats …` appear. If the code expired, run step 3 again.
|
|
13
|
-
5. If step 1 reported `install.kind: "npx"`, run `npm i -g wazap-mcp` first: Claude Desktop and the background service cannot launch from the npx cache, which npm clears. Register wazap with the client you are running in: `npx wazap-mcp connect <client>` where <client> is claude-code, claude-desktop, cursor, codex, vscode or
|
|
13
|
+
5. If step 1 reported `install.kind: "npx"`, run `npm i -g wazap-mcp` first: Claude Desktop and the background service cannot launch from the npx cache, which npm clears. Register wazap with the client you are running in: `npx wazap-mcp connect <client>` where <client> is claude-code, claude-desktop, cursor, codex, vscode, gemini, windsurf or opencode. Tell the person what the command printed as `Next`. Then, for claude-code, codex, cursor and opencode, run `npx wazap-mcp skills install <client>`, which copies the five workflow skills where that client reads them. The other clients need no such step, because the server hands them the same five workflows as MCP prompts.
|
|
14
14
|
6. Verify: `npx wazap-mcp status --json` must show `linked: true` and no failing check. If the client needs a restart, say so; otherwise call the `get_status` tool and then `learn`.
|
|
15
15
|
7. Done when `get_status` returns `connected`. Then offer: "what did I miss on WhatsApp today?"
|
|
16
16
|
8. Ask whether wazap should keep running after this client closes, and whether cloud agents should reach it. For the first, run `npx wazap-mcp service install`, which writes a launchd agent or a systemd user unit and starts it. For the second, also run `npx wazap-mcp expose`. It opens a tunnel, prints an https URL and a consent password, and restarts the service. Give an agent the URL only. The password is for the person, on their consent page; never paste it into a chat, a config file or a message.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
More than one WhatsApp account: `npx wazap-mcp account add <id>` creates a slot, then link it with `login --account <id>` or `link_account(account_id)`. `account list` shows them; `account default <id>` picks the one tools use when a call does not say otherwise. A running server learns a new account only on restart — `account add`/`enable`/`disable`/`default` say so when they change the registry. When several accounts could own a chat, write tools return AMBIGUOUS_ACCOUNT; pass `account_id`. `list_accounts` is the read tool that shows them all.
|
|
19
|
+
|
|
20
|
+
Errors: every wazap error prints `✗ message` and `→ fix`; do what the fix says, do not retry blindly. One process owns the session, so while the `login` of step 3 runs, no server and no other wazap command may touch the same data dir; two servers on one data dir refuse to start. Stop the other one first — `wazap service stop` when the holder is the background service.
|
package/README.md
CHANGED
|
@@ -502,31 +502,57 @@ trace, so an agent can decide whether to retry, ask the user, or stop.
|
|
|
502
502
|
## Data directory
|
|
503
503
|
|
|
504
504
|
Everything lives in `~/.wazap` (override with `--data-dir` or `WAZAP_DATA_DIR`),
|
|
505
|
-
created `0700` with credentials written `0600
|
|
505
|
+
created `0700` with credentials written `0600`. A data dir from before several
|
|
506
|
+
accounts moves into `accounts/default/` the first time a wazap command runs.
|
|
506
507
|
|
|
507
508
|
```
|
|
508
509
|
~/.wazap/
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
.
|
|
510
|
+
accounts.json which accounts exist, and which is default
|
|
511
|
+
accounts/<id>/
|
|
512
|
+
auth/ WhatsApp credentials — treat this like a password
|
|
513
|
+
media/ downloads from download_media
|
|
514
|
+
history/ per-chat message history, so a restart is not amnesia
|
|
515
|
+
previews/ one small JPEG per photo or video already previewed
|
|
516
|
+
notes.json notes on contacts and "handled" marks; never sent anywhere
|
|
517
|
+
store.json chat-list snapshot
|
|
518
|
+
qr.png last QR, when login showed one
|
|
519
|
+
models/ whisper.cpp models, when transcription runs locally
|
|
520
|
+
server.lock pid of the running server
|
|
521
|
+
daemon.json loopback endpoint a second wazap bridges to
|
|
522
|
+
oauth.json registered agents and hashed OAuth grants, when OAuth is on
|
|
523
|
+
.env optional settings, see .env.example
|
|
524
|
+
migration.json written once when a flat dir moved into accounts/default
|
|
520
525
|
```
|
|
521
526
|
|
|
522
527
|
Credential writes go to a temp file and are renamed into place, so killing the
|
|
523
528
|
process mid-write cannot leave you re-linking your phone.
|
|
524
529
|
|
|
530
|
+
## Several accounts
|
|
531
|
+
|
|
532
|
+
One `wazap serve` holds every enabled account in the data dir. Each account is
|
|
533
|
+
its own Baileys socket and its own folder under `accounts/<id>/`. The first
|
|
534
|
+
account is `default`. Add another with `wazap account add work --name Work`,
|
|
535
|
+
then `wazap login --account work`.
|
|
536
|
+
|
|
537
|
+
`--account` picks one on `login`, `logout`, `status`, `config writes` and
|
|
538
|
+
`webhook test`. MCP tools take an optional `account_id`. Call `list_accounts`
|
|
539
|
+
first when more than one is linked. A chat only one account knows selects that
|
|
540
|
+
account. A send to a chat no account knows, with two or more accounts, fails
|
|
541
|
+
`AMBIGUOUS_ACCOUNT` instead of falling back to default.
|
|
542
|
+
|
|
543
|
+
Reads without a chat and without `account_id` use the default account; the
|
|
544
|
+
response still carries `account_id`. `link_account` needs an account that
|
|
545
|
+
already exists. Five accounts is advice, not a cap. One phone number is one
|
|
546
|
+
account.
|
|
547
|
+
|
|
548
|
+
An account can override the global webhook URL and secret in `accounts.json`
|
|
549
|
+
(`webhook_url`, `webhook_secret`). `wazap webhook test --account work` posts
|
|
550
|
+
with that account's id and name.
|
|
551
|
+
|
|
525
552
|
## Several clients at once
|
|
526
553
|
|
|
527
|
-
Claude Desktop, Claude Code and Cursor each launch their own `wazap`.
|
|
528
|
-
|
|
529
|
-
fighting over it. The first `wazap` on a data directory owns the session and
|
|
554
|
+
Claude Desktop, Claude Code and Cursor each launch their own `wazap`. The first
|
|
555
|
+
one on a data directory owns every enabled account, each on its own socket, and
|
|
530
556
|
opens an MCP endpoint on `127.0.0.1`; every later one bridges to it over that
|
|
531
557
|
endpoint. There is nothing to configure, and no client can tell the difference.
|
|
532
558
|
The owner publishes `<data-dir>/daemon.json` (`0600`) with its pid, its port
|
|
@@ -684,15 +710,17 @@ is not posted. The only event is `message_received`.
|
|
|
684
710
|
```bash
|
|
685
711
|
npx wazap-mcp config webhook on # asks for URL + secret (secret is not echoed)
|
|
686
712
|
npx wazap-mcp webhook test # POST a probe event
|
|
713
|
+
npx wazap-mcp webhook test --account work
|
|
687
714
|
npx wazap-mcp config webhook off
|
|
688
715
|
```
|
|
689
716
|
|
|
690
717
|
On without a URL or secret fails `wazap status`, doctor and setup. A failed
|
|
691
718
|
delivery retries twice (200 ms, then 500 ms), then sets `webhook.last_error`
|
|
692
|
-
and leaves WhatsApp and MCP running.
|
|
719
|
+
and leaves WhatsApp and MCP running. An account may set `webhook_url` and
|
|
720
|
+
`webhook_secret` in `accounts.json`; those win over the global URL and secret.
|
|
693
721
|
|
|
694
722
|
HMAC: `X-Wazap-Signature` is `sha256=<hex>`, HMAC-SHA256 of the exact raw
|
|
695
|
-
JSON body with
|
|
723
|
+
JSON body with the secret that signed it. Verify that raw body, not a
|
|
696
724
|
re-serialized object. HTTPS only, except `http://` on loopback.
|
|
697
725
|
|
|
698
726
|
```json
|
|
@@ -702,7 +730,9 @@ re-serialized object. HTTPS only, except `http://` on loopback.
|
|
|
702
730
|
"chat_id": "15550100@s.whatsapp.net",
|
|
703
731
|
"ts": "2026-09-08T14:00:00+00:00",
|
|
704
732
|
"text": "hello, or a short preview",
|
|
705
|
-
"message_id": "false_15550100@s.whatsapp.net_3EB0…"
|
|
733
|
+
"message_id": "false_15550100@s.whatsapp.net_3EB0…",
|
|
734
|
+
"account_id": "default",
|
|
735
|
+
"account_name": "default"
|
|
706
736
|
}
|
|
707
737
|
```
|
|
708
738
|
|
|
@@ -730,8 +760,8 @@ re-serialized object. HTTPS only, except `http://` on loopback.
|
|
|
730
760
|
| `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
|
|
731
761
|
| `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |
|
|
732
762
|
| `WAZAP_WEBHOOK` | `off` | `on` posts live inbound messages to the webhook URL. |
|
|
733
|
-
| `WAZAP_WEBHOOK_URL` | unset | HTTPS endpoint. `http://` only on loopback. |
|
|
734
|
-
| `WAZAP_WEBHOOK_SECRET` | unset | Shared secret for `X-Wazap-Signature`. Never a flag. |
|
|
763
|
+
| `WAZAP_WEBHOOK_URL` | unset | HTTPS endpoint. `http://` only on loopback. An account `webhook_url` wins. |
|
|
764
|
+
| `WAZAP_WEBHOOK_SECRET` | unset | Shared secret for `X-Wazap-Signature`. Never a flag. An account `webhook_secret` wins. |
|
|
735
765
|
|
|
736
766
|
Flags beat environment variables, which beat `<data-dir>/.env`.
|
|
737
767
|
|
package/dist/account-cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { AccountRegistry } from "./accounts.js";
|
|
1
|
+
import { AccountRegistry, ownerNumber } from "./accounts.js";
|
|
2
2
|
import { readLinkedAccount } from "./auth-state.js";
|
|
3
|
-
import { ask, leftoverFix } from "./cli.js";
|
|
3
|
+
import { ask, leftoverFix, warnIfServerRunning } from "./cli.js";
|
|
4
4
|
import { ACCOUNT_USAGE, MIGRATE_USAGE, accountPaths, paths } from "./config.js";
|
|
5
5
|
import { WazapError } from "./errors.js";
|
|
6
6
|
import { lockHolder } from "./lock.js";
|
|
7
7
|
import { say } from "./logger.js";
|
|
8
8
|
import { rollbackMigration } from "./migrate.js";
|
|
9
|
+
import { serviceHolding } from "./service.js";
|
|
9
10
|
import { brand, info, maskNumber, ok } from "./ui.js";
|
|
10
11
|
function parseAccountVerb(verb) {
|
|
11
12
|
switch (verb) {
|
|
@@ -14,6 +15,7 @@ function parseAccountVerb(verb) {
|
|
|
14
15
|
case "remove":
|
|
15
16
|
case "enable":
|
|
16
17
|
case "disable":
|
|
18
|
+
case "default":
|
|
17
19
|
return verb;
|
|
18
20
|
default:
|
|
19
21
|
throw new WazapError("INVALID_ID", `Unknown account command "${verb ?? ""}".`, ACCOUNT_USAGE);
|
|
@@ -26,7 +28,11 @@ export function describeAccount(account) {
|
|
|
26
28
|
}
|
|
27
29
|
export function describeStatusAccount(row) {
|
|
28
30
|
const flag = row.enabled ? "enabled" : "disabled";
|
|
29
|
-
const who = row.account
|
|
31
|
+
const who = row.account !== null
|
|
32
|
+
? describeAccount(row.account)
|
|
33
|
+
: row.owner !== null
|
|
34
|
+
? `was ${maskNumber(ownerNumber(row.owner))}`
|
|
35
|
+
: "not linked";
|
|
30
36
|
return `${row.id} ${flag} ${who}`;
|
|
31
37
|
}
|
|
32
38
|
export function accountRows(config) {
|
|
@@ -45,6 +51,7 @@ export function accountRows(config) {
|
|
|
45
51
|
enabled: record.enabled,
|
|
46
52
|
default: record.id === registry.defaultId(),
|
|
47
53
|
account: linked,
|
|
54
|
+
owner: record.owner ?? null,
|
|
48
55
|
};
|
|
49
56
|
});
|
|
50
57
|
}
|
|
@@ -77,6 +84,11 @@ export async function runAccount(config) {
|
|
|
77
84
|
throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
|
|
78
85
|
enableAccount(config, id, false);
|
|
79
86
|
return;
|
|
87
|
+
case "default":
|
|
88
|
+
if (id === undefined)
|
|
89
|
+
throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
|
|
90
|
+
defaultAccount(config, id);
|
|
91
|
+
return;
|
|
80
92
|
default: {
|
|
81
93
|
const _exhaustive = verb;
|
|
82
94
|
return _exhaustive;
|
|
@@ -91,15 +103,18 @@ function listAccounts(config) {
|
|
|
91
103
|
function addAccount(config, id) {
|
|
92
104
|
const record = AccountRegistry.load(config.dataDir).add(id, config.accountName);
|
|
93
105
|
say(ok(`Account "${record.id}" added.`));
|
|
106
|
+
warnIfServerRunning(config);
|
|
94
107
|
}
|
|
95
108
|
async function removeAccount(config, id) {
|
|
96
109
|
const registry = AccountRegistry.load(config.dataDir);
|
|
97
110
|
if (registry.get(id) === undefined) {
|
|
98
111
|
throw new WazapError("INVALID_ID", `No account "${id}".`, "Run `wazap account list`");
|
|
99
112
|
}
|
|
113
|
+
// Removing an account under a live hub would orphan its socket; even the
|
|
114
|
+
// service must be stopped by hand, so the fix names whichever holder it is.
|
|
100
115
|
const running = lockHolder(paths(config.dataDir).lockFile);
|
|
101
116
|
if (running !== null) {
|
|
102
|
-
throw new WazapError("INVALID_ID", `wazap is running (pid ${running}).`, leftoverFix(running));
|
|
117
|
+
throw new WazapError("INVALID_ID", `wazap is running (pid ${running}).`, leftoverFix(running, serviceHolding(config.dataDir, running) !== null));
|
|
103
118
|
}
|
|
104
119
|
if (!config.assumeYes) {
|
|
105
120
|
if (process.stdin.isTTY !== true) {
|
|
@@ -121,6 +136,12 @@ function enableAccount(config, id, enabled) {
|
|
|
121
136
|
else
|
|
122
137
|
registry.disable(id);
|
|
123
138
|
say(ok(`Account "${id}" ${enabled ? "enabled" : "disabled"}.`));
|
|
139
|
+
warnIfServerRunning(config);
|
|
140
|
+
}
|
|
141
|
+
function defaultAccount(config, id) {
|
|
142
|
+
AccountRegistry.load(config.dataDir).setDefault(id);
|
|
143
|
+
say(ok(`Default account: "${id}".`));
|
|
144
|
+
warnIfServerRunning(config);
|
|
124
145
|
}
|
|
125
146
|
export function runMigrate(config) {
|
|
126
147
|
const [verb] = config.args;
|
package/dist/account-hub.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* a live service for each enabled one, with its own reconnect loop and write
|
|
4
4
|
* bucket. MCP tools resolve a service per call.
|
|
5
5
|
*/
|
|
6
|
+
import { AccountRegistry } from "./accounts.js";
|
|
6
7
|
import { accountPaths } from "./config.js";
|
|
7
8
|
import { WazapError } from "./errors.js";
|
|
8
9
|
import { logError } from "./logger.js";
|
|
@@ -48,6 +49,9 @@ export function singletonSource(wa) {
|
|
|
48
49
|
findByDraft: (did) => (typeof wa.hasDraft === "function" && wa.hasDraft(did) ? [binding] : []),
|
|
49
50
|
record: (requested) => (requested === id ? self() : undefined),
|
|
50
51
|
records: () => [self()],
|
|
52
|
+
// The stub has no disk: what it knows is all there is.
|
|
53
|
+
recordOnDisk: (requested) => (requested === id ? self() : undefined),
|
|
54
|
+
noteOwner: () => { },
|
|
51
55
|
};
|
|
52
56
|
}
|
|
53
57
|
export class AccountHub {
|
|
@@ -56,9 +60,11 @@ export class AccountHub {
|
|
|
56
60
|
givenUp = new Set();
|
|
57
61
|
primary;
|
|
58
62
|
primaryId;
|
|
63
|
+
dataDir;
|
|
59
64
|
/** Process exit hook. Fires only after every enabled account has given up. */
|
|
60
65
|
onGiveUp = null;
|
|
61
66
|
constructor(config, registry) {
|
|
67
|
+
this.dataDir = config.dataDir;
|
|
62
68
|
for (const account of registry.all()) {
|
|
63
69
|
this.known.set(account.id, { ...account });
|
|
64
70
|
}
|
|
@@ -69,6 +75,7 @@ export class AccountHub {
|
|
|
69
75
|
for (const account of enabled) {
|
|
70
76
|
const wa = new WhatsAppService(config, account, accountPaths(config.dataDir, account.id));
|
|
71
77
|
wa.onGiveUp = () => this.noteGiveUp(account.id);
|
|
78
|
+
wa.onLinked = (linked) => this.noteOwner(account.id, linked.id);
|
|
72
79
|
this.services.set(account.id, wa);
|
|
73
80
|
}
|
|
74
81
|
const firstId = enabled[0].id;
|
|
@@ -119,6 +126,30 @@ export class AccountHub {
|
|
|
119
126
|
records() {
|
|
120
127
|
return [...this.known.values()].map((account) => ({ ...account }));
|
|
121
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The registry re-read from disk, so a tool can tell "account added after
|
|
131
|
+
* this server started" apart from "no such account" and ask for a restart.
|
|
132
|
+
*/
|
|
133
|
+
recordOnDisk(id) {
|
|
134
|
+
try {
|
|
135
|
+
return AccountRegistry.load(this.dataDir).get(id);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
noteOwner(id, owner) {
|
|
142
|
+
try {
|
|
143
|
+
AccountRegistry.load(this.dataDir).setOwner(id, owner);
|
|
144
|
+
const record = this.known.get(id);
|
|
145
|
+
if (record !== undefined)
|
|
146
|
+
record.owner = owner;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// A linked session still works; the display field is what is lost.
|
|
150
|
+
logError(`owner persist ${id}`, err);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
122
153
|
noteGiveUp(id) {
|
|
123
154
|
if (this.givenUp.has(id))
|
|
124
155
|
return;
|
package/dist/account-resolve.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Per-call account resolution and the list/status overlays that need the hub.
|
|
3
3
|
* Tool handlers stay in tools.ts.
|
|
4
4
|
*/
|
|
5
|
+
import { ownerNumber } from "./accounts.js";
|
|
5
6
|
import { WazapError } from "./errors.js";
|
|
6
7
|
import { maskNumber } from "./ui.js";
|
|
7
8
|
const FIX_ADD_ACCOUNT = "Run `wazap account add`";
|
|
@@ -38,6 +39,14 @@ function resolveGivenId(hub, requested, toolName) {
|
|
|
38
39
|
throw new WazapError("ACCOUNT_DISABLED", `Account "${requested}" is disabled.`, `Run \`wazap account enable ${requested}\` and restart the server`);
|
|
39
40
|
}
|
|
40
41
|
if (live === undefined) {
|
|
42
|
+
// The registry may have grown since this server started; `record` reads a
|
|
43
|
+
// snapshot, so check disk before calling the id unknown.
|
|
44
|
+
const onDisk = hub.recordOnDisk(requested);
|
|
45
|
+
if (onDisk !== undefined) {
|
|
46
|
+
throw new WazapError("ACCOUNT_NOT_FOUND", `Account "${requested}" was added after this server started.`, onDisk.enabled
|
|
47
|
+
? "Restart the server so it picks up the account (`wazap service restart`, or restart your client)"
|
|
48
|
+
: `Run \`wazap account enable ${requested}\`, then restart the server`);
|
|
49
|
+
}
|
|
41
50
|
const fix = toolName === "link_account" ? FIX_ADD_ACCOUNT : `${FIX_ADD_ACCOUNT}, or call list_accounts`;
|
|
42
51
|
throw new WazapError("ACCOUNT_NOT_FOUND", `No account "${requested}".`, fix);
|
|
43
52
|
}
|
|
@@ -79,7 +88,7 @@ function listedFromRecord(record) {
|
|
|
79
88
|
id: record.id,
|
|
80
89
|
name: record.name,
|
|
81
90
|
status: record.enabled ? "disconnected" : "disabled",
|
|
82
|
-
phone_masked: null,
|
|
91
|
+
phone_masked: record.owner === null ? null : maskNumber(ownerNumber(record.owner)),
|
|
83
92
|
owner_name: null,
|
|
84
93
|
write_tools: false,
|
|
85
94
|
enabled: record.enabled,
|
|
@@ -87,7 +96,9 @@ function listedFromRecord(record) {
|
|
|
87
96
|
}
|
|
88
97
|
function renderAccountLines(rows) {
|
|
89
98
|
return rows.map((row) => {
|
|
90
|
-
const who = row.owner_name
|
|
99
|
+
const who = row.owner_name
|
|
100
|
+
? `${row.owner_name}${row.phone_masked ? ` (${row.phone_masked})` : ""}`
|
|
101
|
+
: (row.phone_masked ?? "not linked");
|
|
91
102
|
const writes = row.write_tools ? "writes on" : "writes off";
|
|
92
103
|
const flag = row.enabled ? row.status : "disabled";
|
|
93
104
|
return `- **${row.id}** (${row.name}) · ${flag} · ${who} · ${writes}`;
|
package/dist/accounts.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
+
import { readLinkedAccount } from "./auth-state.js";
|
|
3
4
|
import { accountPaths, paths } from "./config.js";
|
|
4
5
|
import { WazapError } from "./errors.js";
|
|
5
6
|
export const DEFAULT_ACCOUNT_ID = "default";
|
|
@@ -71,7 +72,24 @@ function parseAccountRecord(value, file) {
|
|
|
71
72
|
}
|
|
72
73
|
record.rate_limit = value.rate_limit;
|
|
73
74
|
}
|
|
74
|
-
return record;
|
|
75
|
+
return { ...record, ...webhookFields(value.id, value.webhook_url, value.webhook_secret, ` in ${file}`) };
|
|
76
|
+
}
|
|
77
|
+
/** Shared by load and `setWebhook` so a writer cannot persist what load refuses. */
|
|
78
|
+
function webhookFields(id, url, secret, where = "", fix = "Fix or remove accounts.json") {
|
|
79
|
+
const fields = {};
|
|
80
|
+
if (url !== undefined) {
|
|
81
|
+
if (typeof url !== "string" || url.trim() === "") {
|
|
82
|
+
throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_url.`, fix);
|
|
83
|
+
}
|
|
84
|
+
fields.webhook_url = url.trim().replace(/\/+$/, "");
|
|
85
|
+
}
|
|
86
|
+
if (secret !== undefined) {
|
|
87
|
+
if (typeof secret !== "string" || secret === "") {
|
|
88
|
+
throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_secret.`, fix);
|
|
89
|
+
}
|
|
90
|
+
fields.webhook_secret = secret;
|
|
91
|
+
}
|
|
92
|
+
return fields;
|
|
75
93
|
}
|
|
76
94
|
function parseAccountsFile(value, file) {
|
|
77
95
|
if (!isRecord(value) || value.v !== 2 || typeof value.default !== "string" || !Array.isArray(value.accounts)) {
|
|
@@ -179,6 +197,28 @@ export class AccountRegistry {
|
|
|
179
197
|
setWrites(id, writes) {
|
|
180
198
|
this.commit(this.withAccount(id, (account) => ({ ...account, writes })));
|
|
181
199
|
}
|
|
200
|
+
setWebhook(id, webhook) {
|
|
201
|
+
this.commit(this.withAccount(id, (account) => ({
|
|
202
|
+
...account,
|
|
203
|
+
...webhookFields(id, webhook.url, webhook.secret, "", "Set a non-empty webhook URL or secret"),
|
|
204
|
+
})));
|
|
205
|
+
}
|
|
206
|
+
/** Drop the per-account override so the account follows the global webhook again. */
|
|
207
|
+
clearWebhook(id) {
|
|
208
|
+
this.commit(this.withAccount(id, (account) => {
|
|
209
|
+
const next = { ...account };
|
|
210
|
+
delete next.webhook_url;
|
|
211
|
+
delete next.webhook_secret;
|
|
212
|
+
return next;
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
setDefault(id) {
|
|
216
|
+
const slug = parseAccountId(id);
|
|
217
|
+
if (!this.file.accounts.some((account) => account.id === slug)) {
|
|
218
|
+
throw new WazapError("INVALID_ID", `No account "${slug}".`, FIX_LIST);
|
|
219
|
+
}
|
|
220
|
+
this.commit({ ...this.file, default: slug });
|
|
221
|
+
}
|
|
182
222
|
commit(next) {
|
|
183
223
|
writeJsonFile(paths(this.dataDir).accountsFile, next);
|
|
184
224
|
this.file = next;
|
|
@@ -215,3 +255,21 @@ export function ensureAccountsFile(dataDir) {
|
|
|
215
255
|
registry.save();
|
|
216
256
|
return registry;
|
|
217
257
|
}
|
|
258
|
+
/** The digits of an owner jid like `40734…:75@s.whatsapp.net`, for display. */
|
|
259
|
+
export function ownerNumber(owner) {
|
|
260
|
+
return owner.split(/[:@]/)[0] ?? "";
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Whether any configured account has linked credentials on disk. `status` and
|
|
264
|
+
* the doctor checks answer for the data dir, not only the selected account.
|
|
265
|
+
*/
|
|
266
|
+
export function anyAccountLinked(dataDir, registry = AccountRegistry.load(dataDir)) {
|
|
267
|
+
return registry.all().some((account) => {
|
|
268
|
+
try {
|
|
269
|
+
return readLinkedAccount(accountPaths(dataDir, account.id).authDir) !== null;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -68,16 +68,20 @@ export async function runStatus(config) {
|
|
|
68
68
|
const serverPid = lockHolder(p.lockFile);
|
|
69
69
|
const daemon = readDaemon(p.daemonFile);
|
|
70
70
|
const sharing = daemon !== null && daemon.pid === serverPid ? { pid: daemon.pid, port: daemon.port } : null;
|
|
71
|
+
const rows = accountRows(config);
|
|
71
72
|
const report = {
|
|
72
73
|
data_dir: config.dataDir,
|
|
73
|
-
linked
|
|
74
|
+
// The dir is linked when any account is; `account` still describes the
|
|
75
|
+
// selected one, and `accounts` carries each row's own credentials.
|
|
76
|
+
linked: account !== null || rows.some((row) => row.account !== null),
|
|
74
77
|
credentials_readable: !unreadable,
|
|
75
78
|
account,
|
|
76
|
-
accounts:
|
|
79
|
+
accounts: rows,
|
|
77
80
|
wazap_version: WAZAP_VERSION,
|
|
78
81
|
baileys_version: BAILEYS_VERSION,
|
|
79
82
|
install: whereInstalled(),
|
|
80
83
|
server_pid: serverPid,
|
|
84
|
+
server_is_service: serverPid !== null && serviceHolding(config.dataDir, serverPid) !== null,
|
|
81
85
|
daemon: sharing,
|
|
82
86
|
checks: await runChecks(config),
|
|
83
87
|
};
|
|
@@ -102,15 +106,10 @@ export async function runStatus(config) {
|
|
|
102
106
|
/** Today's phrasing, kept verbatim so pipes and log captures keep parsing. */
|
|
103
107
|
function plainStatus(report) {
|
|
104
108
|
const lines = [`data dir: ${report.data_dir}`];
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
lines.push("linked: yes", `account: ${describeAccount(report.account)}`);
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
lines.push("linked: no");
|
|
113
|
-
}
|
|
109
|
+
const credsNote = report.credentials_readable ? "" : " (credentials unreadable — run `wazap logout` then `wazap login`)";
|
|
110
|
+
lines.push(`linked: ${report.linked ? "yes" : "no"}${credsNote}`);
|
|
111
|
+
if (report.account)
|
|
112
|
+
lines.push(`account: ${describeAccount(report.account)}`);
|
|
114
113
|
if (report.accounts.length > 1) {
|
|
115
114
|
lines.push("accounts:");
|
|
116
115
|
for (const row of report.accounts) {
|
|
@@ -360,9 +359,19 @@ async function testTranscribe(settings, file) {
|
|
|
360
359
|
say(ok(facts.filter((fact) => fact !== undefined).join(" · ")));
|
|
361
360
|
say(`"${transcript.text}"`);
|
|
362
361
|
}
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
|
|
362
|
+
/**
|
|
363
|
+
* The one line that names the leftover pid, so greet, login and logout cannot
|
|
364
|
+
* drift. `kill` on a service-held pid just gets it restarted by the supervisor,
|
|
365
|
+
* so that case names the service verb instead.
|
|
366
|
+
*/
|
|
367
|
+
export function leftoverFix(pid, heldByService = false) {
|
|
368
|
+
return heldByService ? "stop it first: `wazap service stop`" : `stop it first: kill ${pid}`;
|
|
369
|
+
}
|
|
370
|
+
/** A mutation the running server cannot see asks for a restart, on the spot. */
|
|
371
|
+
export function warnIfServerRunning(config) {
|
|
372
|
+
const running = lockHolder(paths(config.dataDir).lockFile);
|
|
373
|
+
if (running !== null)
|
|
374
|
+
say(warn(`A server is running (pid ${running}); restart it for this to apply.`));
|
|
366
375
|
}
|
|
367
376
|
/**
|
|
368
377
|
* A client-launched wazap holding the session, or null if the lock is free or
|
|
@@ -382,7 +391,7 @@ export function greetNext(report) {
|
|
|
382
391
|
if (report.server_pid !== null) {
|
|
383
392
|
lines.push(info(`A server is already running (pid ${report.server_pid}).`));
|
|
384
393
|
if (!report.linked || !report.credentials_readable) {
|
|
385
|
-
lines.push(fix(leftoverFix(report.server_pid)));
|
|
394
|
+
lines.push(fix(leftoverFix(report.server_pid, report.server_is_service === true)));
|
|
386
395
|
}
|
|
387
396
|
}
|
|
388
397
|
if (!report.credentials_readable) {
|
|
@@ -717,7 +726,7 @@ const SERVICE_STOP_MS = 10_000;
|
|
|
717
726
|
* quit; the background service is ours to stop, which is why the returned
|
|
718
727
|
* function starts it again. What `~/.wazap/link.sh` did by hand.
|
|
719
728
|
*/
|
|
720
|
-
export async function yieldSession(config, lockFile) {
|
|
729
|
+
export async function yieldSession(config, lockFile, why = "pairing") {
|
|
721
730
|
const running = takeSessionLock(lockFile);
|
|
722
731
|
if (running === null)
|
|
723
732
|
return () => { };
|
|
@@ -725,7 +734,7 @@ export async function yieldSession(config, lockFile) {
|
|
|
725
734
|
if (held === null) {
|
|
726
735
|
throw leftoverRefusal(config) ?? new WazapError("WHATSAPP_ERROR", `wazap is running (pid ${running}).`, leftoverFix(running));
|
|
727
736
|
}
|
|
728
|
-
say(info(
|
|
737
|
+
say(info(`Stopping the wazap service for ${why}`));
|
|
729
738
|
held.supervisor.stop(held.record);
|
|
730
739
|
let resumed = false;
|
|
731
740
|
const resume = () => {
|
|
@@ -841,44 +850,45 @@ class Countdown {
|
|
|
841
850
|
export async function runLogout(config) {
|
|
842
851
|
const p = paths(config.dataDir);
|
|
843
852
|
const selected = resolveAccount(config.dataDir, config.accountId);
|
|
844
|
-
const
|
|
845
|
-
if (running !== null) {
|
|
846
|
-
say(fail(`wazap is running (pid ${running}).`));
|
|
847
|
-
say(fix(leftoverFix(running)));
|
|
848
|
-
process.exit(1);
|
|
849
|
-
}
|
|
850
|
-
let linked = null;
|
|
851
|
-
let unreadable = false;
|
|
853
|
+
const resumeService = await yieldSession(config, p.lockFile, "logout");
|
|
852
854
|
try {
|
|
853
|
-
linked =
|
|
854
|
-
|
|
855
|
-
catch {
|
|
856
|
-
// Unreadable creds are exactly what logout exists to clear, so keep going.
|
|
857
|
-
unreadable = true;
|
|
858
|
-
}
|
|
859
|
-
if (!linked && !unreadable) {
|
|
860
|
-
say(info("Not linked."));
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
if (linked) {
|
|
864
|
-
const deadline = Date.now() + LOGOUT_TIMEOUT_MS;
|
|
855
|
+
let linked = null;
|
|
856
|
+
let unreadable = false;
|
|
865
857
|
try {
|
|
866
|
-
|
|
867
|
-
await withDeadline(sock.logout(), deadline, "WhatsApp did not confirm the unlink in time.");
|
|
858
|
+
linked = readLinkedAccount(selected.paths.authDir);
|
|
868
859
|
}
|
|
869
|
-
catch
|
|
870
|
-
|
|
871
|
-
|
|
860
|
+
catch {
|
|
861
|
+
// Unreadable creds are exactly what logout exists to clear, so keep going.
|
|
862
|
+
unreadable = true;
|
|
863
|
+
}
|
|
864
|
+
if (!linked && !unreadable) {
|
|
865
|
+
say(info("Not linked."));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (linked) {
|
|
869
|
+
const deadline = Date.now() + LOGOUT_TIMEOUT_MS;
|
|
870
|
+
try {
|
|
871
|
+
const sock = await linkSession(selected.paths.authDir, { deadline });
|
|
872
|
+
await withDeadline(sock.logout(), deadline, "WhatsApp did not confirm the unlink in time.");
|
|
872
873
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
874
|
+
catch (err) {
|
|
875
|
+
if (alreadyUnlinked(err)) {
|
|
876
|
+
say(info("WhatsApp had already unlinked this device."));
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
logError("unlink from WhatsApp", err);
|
|
880
|
+
say(warn("Could not tell WhatsApp to unlink; remove this device from your phone if it is still listed."));
|
|
881
|
+
}
|
|
876
882
|
}
|
|
877
883
|
}
|
|
884
|
+
clearSession(selected.paths);
|
|
885
|
+
selected.registry.setOwner(selected.account.id, null);
|
|
886
|
+
say(ok("Logged out. Local credentials deleted."));
|
|
887
|
+
}
|
|
888
|
+
finally {
|
|
889
|
+
releaseLock(p.lockFile);
|
|
890
|
+
resumeService();
|
|
878
891
|
}
|
|
879
|
-
clearSession(selected.paths);
|
|
880
|
-
selected.registry.setOwner(selected.account.id, null);
|
|
881
|
-
say(ok("Logged out. Local credentials deleted."));
|
|
882
892
|
process.exit(0);
|
|
883
893
|
}
|
|
884
894
|
/**
|
package/dist/config.js
CHANGED
|
@@ -56,7 +56,7 @@ const COMMAND_ARGS = {
|
|
|
56
56
|
migrate: [1],
|
|
57
57
|
};
|
|
58
58
|
const COMMANDS = Object.keys(COMMAND_ARGS);
|
|
59
|
-
export const ACCOUNT_USAGE = "Run `wazap account add <id> [--name <name>]`, `wazap account remove|enable|disable <id>`, or `wazap account list`";
|
|
59
|
+
export const ACCOUNT_USAGE = "Run `wazap account add <id> [--name <name>]`, `wazap account remove|enable|disable|default <id>`, or `wazap account list`";
|
|
60
60
|
export const MIGRATE_USAGE = "Run `wazap migrate rollback`";
|
|
61
61
|
/**
|
|
62
62
|
* What to type instead of `--help` when the arity is wrong. Literals, not
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
-
import { accountPolicy, resolveAccount } from "./accounts.js";
|
|
2
|
+
import { AccountRegistry, accountPolicy, anyAccountLinked, resolveAccount } from "./accounts.js";
|
|
3
3
|
import { readLinkedAccount } from "./auth-state.js";
|
|
4
|
-
import { WAZAP_VERSION, WRITES_ENABLE_FIX, WRITE_TOKEN_NOTE, isRemoteHttp, paths } from "./config.js";
|
|
4
|
+
import { WAZAP_VERSION, WRITES_ENABLE_FIX, WRITE_TOKEN_NOTE, accountPaths, isRemoteHttp, paths } from "./config.js";
|
|
5
5
|
import { asWazapError } from "./errors.js";
|
|
6
6
|
import { lockHolder, lockPid } from "./lock.js";
|
|
7
7
|
import { oauthProblem, readGrants } from "./oauth.js";
|
|
@@ -36,7 +36,7 @@ export async function runChecks(config) {
|
|
|
36
36
|
checks.push(...[await check(config)].flat());
|
|
37
37
|
let linked = false;
|
|
38
38
|
try {
|
|
39
|
-
linked =
|
|
39
|
+
linked = anyAccountLinked(config.dataDir);
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
42
42
|
linked = false;
|
|
@@ -122,7 +122,7 @@ function checkService(config) {
|
|
|
122
122
|
if (pid === null) {
|
|
123
123
|
return { name: "service", state: "fail", detail: "installed but not running", fix: "run `wazap service start`" };
|
|
124
124
|
}
|
|
125
|
-
if (record.installedVersion
|
|
125
|
+
if (isNewer(WAZAP_VERSION, record.installedVersion)) {
|
|
126
126
|
return {
|
|
127
127
|
name: "service",
|
|
128
128
|
state: "info",
|
|
@@ -130,21 +130,42 @@ function checkService(config) {
|
|
|
130
130
|
fix: "run `wazap service restart`",
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
|
+
if (isNewer(record.installedVersion, WAZAP_VERSION)) {
|
|
134
|
+
return {
|
|
135
|
+
name: "service",
|
|
136
|
+
state: "info",
|
|
137
|
+
detail: `runs ${record.installedVersion}, but only ${WAZAP_VERSION} is installed`,
|
|
138
|
+
fix: "run `wazap update` — restarting keeps the newer build",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
133
141
|
return { name: "service", state: "ok", detail: `running (pid ${pid}, ${supervisor.name})` };
|
|
134
142
|
}
|
|
135
143
|
function checkCredentials(config) {
|
|
136
|
-
|
|
144
|
+
// The data dir is linked when any account is; every corrupt record fails,
|
|
145
|
+
// naming its account.
|
|
146
|
+
let records;
|
|
137
147
|
try {
|
|
138
|
-
|
|
139
|
-
// The number is deliberately absent: status is the thing people screenshot.
|
|
140
|
-
return account === null
|
|
141
|
-
? { name: "credentials", state: "info", detail: "no account linked yet" }
|
|
142
|
-
: { name: "credentials", state: "ok", detail: "readable" };
|
|
148
|
+
records = AccountRegistry.load(config.dataDir).all();
|
|
143
149
|
}
|
|
144
150
|
catch (err) {
|
|
145
151
|
const wazap = err;
|
|
146
152
|
return { name: "credentials", state: "fail", detail: wazap.message, fix: wazap.fix };
|
|
147
153
|
}
|
|
154
|
+
const linkedIds = [];
|
|
155
|
+
for (const record of records) {
|
|
156
|
+
try {
|
|
157
|
+
if (readLinkedAccount(accountPaths(config.dataDir, record.id).authDir) !== null)
|
|
158
|
+
linkedIds.push(record.id);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
const wazap = err;
|
|
162
|
+
return { name: "credentials", state: "fail", detail: `${record.id}: ${wazap.message}`, fix: wazap.fix };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (linkedIds.length === 0)
|
|
166
|
+
return { name: "credentials", state: "info", detail: "no account linked yet" };
|
|
167
|
+
// The number is deliberately absent: status is the thing people screenshot.
|
|
168
|
+
return { name: "credentials", state: "ok", detail: records.length > 1 ? `readable (${linkedIds.join(", ")})` : "readable" };
|
|
148
169
|
}
|
|
149
170
|
function checkWrites(config) {
|
|
150
171
|
const selected = resolveAccount(config.dataDir, config.accountId);
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ Usage:
|
|
|
26
26
|
wazap expose [tailscale|cloudflare|off] Give the running service a public https URL cloud agents can reach
|
|
27
27
|
wazap config [writes on|off] [transcribe local|openai|off] [webhook on|off]
|
|
28
28
|
Show the effective settings, or change one
|
|
29
|
-
wazap webhook test
|
|
29
|
+
wazap webhook test [--account <id>] POST a test message_received to the configured webhook
|
|
30
30
|
wazap transcribe download [--model <alias>] Fetch the whisper.cpp model into the data dir
|
|
31
31
|
wazap transcribe test <audio file> Transcribe a local file with the configured provider
|
|
32
32
|
wazap contacts resync Fetch the phone's address book from WhatsApp again
|
|
@@ -34,7 +34,7 @@ Usage:
|
|
|
34
34
|
wazap status [--live] [--json] [--account <id>] Check the install, the session and the server
|
|
35
35
|
wazap logout [--account <id>] Unlink and delete local credentials
|
|
36
36
|
wazap account add <id> [--name <name>] Add an account slot
|
|
37
|
-
wazap account remove|enable|disable <id>
|
|
37
|
+
wazap account remove|enable|disable|default <id> Change an account, or delete its local data
|
|
38
38
|
wazap account list List accounts in this data dir
|
|
39
39
|
wazap migrate rollback Undo a flat-layout move into accounts/
|
|
40
40
|
|
|
@@ -44,7 +44,8 @@ Tunnel providers for wazap expose: ${PROVIDER_NAMES}.
|
|
|
44
44
|
|
|
45
45
|
Options:
|
|
46
46
|
--data-dir <path> Where wazap keeps its data (default ~/.wazap, or $WAZAP_DATA_DIR)
|
|
47
|
-
--account <id> With login, logout, status, contacts, config writes
|
|
47
|
+
--account <id> With login, logout, status, contacts, config writes|webhook, webhook test:
|
|
48
|
+
pick this account. account add|enable|disable|default apply on server restart
|
|
48
49
|
--name <name> With account add: a display name
|
|
49
50
|
--read-only Refuse every write; the write tools are not registered at all
|
|
50
51
|
--http Serve Streamable HTTP instead of stdio
|
|
@@ -89,8 +90,11 @@ async function main() {
|
|
|
89
90
|
}
|
|
90
91
|
const { config } = invocation;
|
|
91
92
|
// Rollback is the inverse of this move. Running it first would re-apply a
|
|
92
|
-
// half-finished migrate and then fail to undo it.
|
|
93
|
-
|
|
93
|
+
// half-finished migrate and then fail to undo it. The other exempt commands
|
|
94
|
+
// never open account state, and `service stop` is how a lock that blocks the
|
|
95
|
+
// migration is released.
|
|
96
|
+
const MIGRATE_EXEMPT = new Set(["migrate", "service", "connect", "skills", "expose", "update", "transcribe"]);
|
|
97
|
+
if (!MIGRATE_EXEMPT.has(config.command))
|
|
94
98
|
migrateLayout(config.dataDir);
|
|
95
99
|
switch (config.command) {
|
|
96
100
|
case "serve":
|
package/dist/migrate.js
CHANGED
|
@@ -2,8 +2,9 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync,
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { AccountRegistry, DEFAULT_ACCOUNT_ID, ensureAccountsFile, isRecord, writeJsonFile } from "./accounts.js";
|
|
4
4
|
import { readLinkedAccount } from "./auth-state.js";
|
|
5
|
-
import { accountPaths } from "./config.js";
|
|
5
|
+
import { accountPaths, paths } from "./config.js";
|
|
6
6
|
import { WazapError } from "./errors.js";
|
|
7
|
+
import { lockHolder } from "./lock.js";
|
|
7
8
|
/** Flat-layout names that move into accounts/default/. qr.png is leftover login art. */
|
|
8
9
|
export const LAYOUT_ENTRIES = ["auth", "store.json", "history", "media", "previews", "notes.json", "qr.png"];
|
|
9
10
|
function isDir(path) {
|
|
@@ -37,6 +38,17 @@ function leftoverEntries(dataDir) {
|
|
|
37
38
|
function rollbackFix(dataDir) {
|
|
38
39
|
return `Run \`wazap migrate rollback --data-dir ${dataDir}\``;
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Moving `auth/` out from under a live process breaks it, and whatever it
|
|
43
|
+
* recreates at the root then blocks both migrate and rollback. The holder can
|
|
44
|
+
* only be a pre-migration build: every newer one migrated at its own start.
|
|
45
|
+
*/
|
|
46
|
+
function refuseWhileRunning(dataDir) {
|
|
47
|
+
const running = lockHolder(paths(dataDir).lockFile);
|
|
48
|
+
if (running === null)
|
|
49
|
+
return;
|
|
50
|
+
throw new WazapError("WHATSAPP_ERROR", `wazap is running (pid ${running}) on the old data layout.`, `stop it first (\`wazap service stop\` if it is the background service, otherwise \`kill ${running}\`), then run this again`);
|
|
51
|
+
}
|
|
40
52
|
function migrateFail(dataDir, message) {
|
|
41
53
|
return new WazapError("WHATSAPP_ERROR", message, rollbackFix(dataDir));
|
|
42
54
|
}
|
|
@@ -165,6 +177,7 @@ export function migrateLayout(dataDir) {
|
|
|
165
177
|
const manifest = readManifest(manifestFile, dataDir);
|
|
166
178
|
const defaultRoot = accountPaths(dataDir, DEFAULT_ACCOUNT_ID).root;
|
|
167
179
|
if (leftover.length > 0) {
|
|
180
|
+
refuseWhileRunning(dataDir);
|
|
168
181
|
applyMigration(dataDir, manifestFile, manifest);
|
|
169
182
|
return;
|
|
170
183
|
}
|
|
@@ -176,6 +189,7 @@ export function migrateLayout(dataDir) {
|
|
|
176
189
|
* later add cannot be smashed back into a flat v0 dir.
|
|
177
190
|
*/
|
|
178
191
|
export function rollbackMigration(dataDir) {
|
|
192
|
+
refuseWhileRunning(dataDir);
|
|
179
193
|
const manifestFile = join(dataDir, "migration.json");
|
|
180
194
|
const manifest = readManifest(manifestFile, dataDir);
|
|
181
195
|
if (manifest === null) {
|
package/dist/settings.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { AccountRegistry, accountPolicy, resolveAccount } from "./accounts.js";
|
|
4
|
-
import { ask, askSecret } from "./cli.js";
|
|
4
|
+
import { ask, askSecret, warnIfServerRunning } from "./cli.js";
|
|
5
5
|
import { paths, writesHints } from "./config.js";
|
|
6
6
|
import { WazapError, asWazapError } from "./errors.js";
|
|
7
7
|
import { lockHolder } from "./lock.js";
|
|
8
8
|
import { say } from "./logger.js";
|
|
9
9
|
import { maskKey, readTranscribeSettings, requireSafeUrl, stripPasted, transcribeReady, } from "./transcribe/index.js";
|
|
10
10
|
import { brand, dim, fix, ok, shortPath, warn } from "./ui.js";
|
|
11
|
-
import { WEBHOOK_ON_FIX, WEBHOOK_TEST_FIX, WebhookSink, readWebhookSettings, requireWebhookUrl } from "./webhook.js";
|
|
11
|
+
import { WEBHOOK_ON_FIX, WEBHOOK_TEST_FIX, WebhookSink, readWebhookSettings, requireWebhookUrl, } from "./webhook.js";
|
|
12
12
|
/**
|
|
13
13
|
* dotenv 16 treats an unquoted `#` as a comment, even with no space, and
|
|
14
14
|
* trims unquoted padding. Quote so a secret like `p@ss#word` round-trips.
|
|
@@ -152,16 +152,20 @@ function transcribeRows(config) {
|
|
|
152
152
|
rows.push(`api key: ${maskKey(settings.apiKey)}`);
|
|
153
153
|
return rows;
|
|
154
154
|
}
|
|
155
|
+
function accountWebhook(config) {
|
|
156
|
+
const selected = resolveAccount(config.dataDir, config.accountId);
|
|
157
|
+
const override = { url: selected.account.webhook_url, secret: selected.account.webhook_secret };
|
|
158
|
+
const source = override.url !== undefined || override.secret !== undefined ? "accounts.json" : config.sources.webhook;
|
|
159
|
+
return { override, source };
|
|
160
|
+
}
|
|
155
161
|
function webhookRows(config) {
|
|
156
|
-
const
|
|
162
|
+
const { override, source } = accountWebhook(config);
|
|
163
|
+
const settings = readWebhookSettings(process.env, override);
|
|
157
164
|
switch (settings.kind) {
|
|
158
165
|
case "off":
|
|
159
|
-
return [`webhook: off (${
|
|
166
|
+
return [`webhook: off (${source})`];
|
|
160
167
|
case "ready":
|
|
161
|
-
return [
|
|
162
|
-
`webhook: on (${new URL(settings.url).host}) (${config.sources.webhook})`,
|
|
163
|
-
`secret: ${maskKey(settings.secret)}`,
|
|
164
|
-
];
|
|
168
|
+
return [`webhook: on (${new URL(settings.url).host}) (${source})`, `secret: ${maskKey(settings.secret)}`];
|
|
165
169
|
case "invalid":
|
|
166
170
|
return [`webhook: ${settings.detail}${settings.fix === "" ? "" : ` — ${settings.fix}`}`];
|
|
167
171
|
default: {
|
|
@@ -176,6 +180,16 @@ async function applyWebhook(config, value) {
|
|
|
176
180
|
return;
|
|
177
181
|
}
|
|
178
182
|
if (value === "off") {
|
|
183
|
+
if (config.accountId !== undefined) {
|
|
184
|
+
// The on/off switch is global; only the endpoint is per-account. `off`
|
|
185
|
+
// scoped to an account drops its override so it follows the global again.
|
|
186
|
+
AccountRegistry.load(config.dataDir).clearWebhook(config.accountId);
|
|
187
|
+
const global = readWebhookSettings(process.env);
|
|
188
|
+
say(ok(`webhook override removed for ${config.accountId} — the global webhook applies${global.kind === "off" ? " (off)" : ""}.`));
|
|
189
|
+
say(dim(`Stored in ${shortPath(paths(config.dataDir).accountsFile)}.`));
|
|
190
|
+
warnIfServerRunning(config);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
179
193
|
setWebhookFlag(config, "off");
|
|
180
194
|
say(ok("webhook: off — live inbound messages are not posted anywhere. Turn it on with `wazap config webhook on`."));
|
|
181
195
|
say(dim(`Stored in ${shortPath(paths(config.dataDir).envFile)}.`));
|
|
@@ -189,9 +203,12 @@ export async function runWebhook(config) {
|
|
|
189
203
|
if (verb !== "test") {
|
|
190
204
|
throw new WazapError("INVALID_ID", `Cannot run \`wazap webhook ${config.args.join(" ")}\`.`, WEBHOOK_TEST_FIX);
|
|
191
205
|
}
|
|
192
|
-
await testWebhook();
|
|
206
|
+
await testWebhook(config);
|
|
193
207
|
}
|
|
194
208
|
async function enableWebhook(config) {
|
|
209
|
+
// An unknown --account fails before any prompt asks for a URL or a secret.
|
|
210
|
+
if (config.accountId !== undefined)
|
|
211
|
+
resolveAccount(config.dataDir, config.accountId);
|
|
195
212
|
// A pipe is consumed whole by the first readline, so a script sets the URL
|
|
196
213
|
// in the environment and only types the secret, the way transcribe does.
|
|
197
214
|
let url;
|
|
@@ -214,6 +231,15 @@ async function enableWebhook(config) {
|
|
|
214
231
|
throw new WazapError("INVALID_ID", "No webhook secret was typed.", "Run `wazap config webhook on` again");
|
|
215
232
|
}
|
|
216
233
|
const p = paths(config.dataDir);
|
|
234
|
+
if (config.accountId !== undefined) {
|
|
235
|
+
// The switch stays global; the URL and secret are this account's override.
|
|
236
|
+
AccountRegistry.load(config.dataDir).setWebhook(config.accountId, { url, secret });
|
|
237
|
+
setEnvSetting(p.envFile, "WAZAP_WEBHOOK", "on");
|
|
238
|
+
say(ok(`webhook: on for ${config.accountId} — live inbound messages POST to ${new URL(url).host} as message_received.`));
|
|
239
|
+
say(dim(`URL and secret stored in ${shortPath(p.accountsFile)}; WAZAP_WEBHOOK=on in ${shortPath(p.envFile)}.`));
|
|
240
|
+
warnIfServerRunning(config);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
217
243
|
setEnvSetting(p.envFile, "WAZAP_WEBHOOK", "on");
|
|
218
244
|
setEnvSetting(p.envFile, "WAZAP_WEBHOOK_URL", url);
|
|
219
245
|
setEnvSetting(p.envFile, "WAZAP_WEBHOOK_SECRET", secret);
|
|
@@ -224,19 +250,15 @@ async function enableWebhook(config) {
|
|
|
224
250
|
function setWebhookFlag(config, value) {
|
|
225
251
|
setEnvSetting(paths(config.dataDir).envFile, "WAZAP_WEBHOOK", value);
|
|
226
252
|
}
|
|
227
|
-
async function testWebhook() {
|
|
228
|
-
const
|
|
253
|
+
async function testWebhook(config) {
|
|
254
|
+
const selected = resolveAccount(config.dataDir, config.accountId);
|
|
255
|
+
const result = await new WebhookSink(process.env, { account: selected.account }).sendTest();
|
|
229
256
|
if (result.ok) {
|
|
230
257
|
say(ok("webhook: test delivered"));
|
|
231
258
|
return;
|
|
232
259
|
}
|
|
233
260
|
throw new WazapError("INVALID_ID", result.error, result.fix === "" ? WEBHOOK_ON_FIX : result.fix);
|
|
234
261
|
}
|
|
235
|
-
function warnIfServerRunning(config) {
|
|
236
|
-
const running = lockHolder(paths(config.dataDir).lockFile);
|
|
237
|
-
if (running !== null)
|
|
238
|
-
say(warn(`A server is running (pid ${running}); restart it for this to apply.`));
|
|
239
|
-
}
|
|
240
262
|
const DEFAULT_URL = "https://api.openai.com/v1";
|
|
241
263
|
const TRANSCRIBE_SAID = {
|
|
242
264
|
local: "transcribe: local — whisper.cpp runs here, and the audio never leaves this machine.",
|
|
@@ -301,6 +323,10 @@ export function applyWrites(config, allowWrites) {
|
|
|
301
323
|
? `writes: on for ${config.accountId} — the agent can send from this account. Turn it off with \`wazap config writes off --account ${config.accountId}\`.`
|
|
302
324
|
: `writes: off for ${config.accountId} — the agent can only read this account. Turn it on with \`wazap config writes on --account ${config.accountId}\`.`));
|
|
303
325
|
say(dim(`Stored in ${shortPath(p.accountsFile)}.`));
|
|
326
|
+
// The account flag is set, but the global switch is a hard off either way.
|
|
327
|
+
if (allowWrites && config.readOnly) {
|
|
328
|
+
say(warn("Global read-only is still on, so writes stay off until `wazap config writes on` clears it."));
|
|
329
|
+
}
|
|
304
330
|
}
|
|
305
331
|
else {
|
|
306
332
|
setEnvSetting(p.envFile, "WAZAP_READ_ONLY", allowWrites ? "0" : "1");
|
package/dist/webhook.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* W1 outbound webhook: one
|
|
3
|
-
*
|
|
2
|
+
* W1 outbound webhook: one live event (`message_received`). Global URL and
|
|
3
|
+
* secret live in `.env`; an account may override either. Delivery never throws
|
|
4
|
+
* into the WhatsApp or MCP path.
|
|
4
5
|
*/
|
|
5
6
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
6
7
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
@@ -19,7 +20,7 @@ const OFF = new Set(["", "off", "0", "no", "none", "false"]);
|
|
|
19
20
|
const ON = new Set(["on", "1", "true", "yes"]);
|
|
20
21
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
21
22
|
/** The one place the webhook environment becomes typed. */
|
|
22
|
-
export function readWebhookSettings(env = process.env) {
|
|
23
|
+
export function readWebhookSettings(env = process.env, override = {}) {
|
|
23
24
|
const raw = stripPasted(env.WAZAP_WEBHOOK ?? "");
|
|
24
25
|
const flag = raw.toLowerCase();
|
|
25
26
|
if (OFF.has(flag))
|
|
@@ -31,8 +32,10 @@ export function readWebhookSettings(env = process.env) {
|
|
|
31
32
|
fix: "set WAZAP_WEBHOOK to on or off",
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
|
-
const
|
|
35
|
-
const
|
|
35
|
+
const overrideUrl = stripPasted(override.url ?? "");
|
|
36
|
+
const overrideSecret = stripPasted(override.secret ?? "");
|
|
37
|
+
const urlRaw = (overrideUrl || stripPasted(env.WAZAP_WEBHOOK_URL ?? "")).replace(/\/+$/, "");
|
|
38
|
+
const secret = overrideSecret || stripPasted(env.WAZAP_WEBHOOK_SECRET ?? "");
|
|
36
39
|
const missingUrl = urlRaw === "";
|
|
37
40
|
const missingSecret = secret === "";
|
|
38
41
|
if (missingUrl && missingSecret) {
|
|
@@ -84,7 +87,7 @@ export function previewText(text) {
|
|
|
84
87
|
return text;
|
|
85
88
|
return `${text.slice(0, WEBHOOK_TEXT_MAX - 1)}…`;
|
|
86
89
|
}
|
|
87
|
-
export function asWebhookPayload(view) {
|
|
90
|
+
export function asWebhookPayload(view, account) {
|
|
88
91
|
return {
|
|
89
92
|
event: WEBHOOK_EVENT,
|
|
90
93
|
from: view.sender.phone ?? view.sender.id,
|
|
@@ -92,6 +95,8 @@ export function asWebhookPayload(view) {
|
|
|
92
95
|
ts: view.timestamp,
|
|
93
96
|
text: previewText(view.text),
|
|
94
97
|
message_id: view.message_id,
|
|
98
|
+
account_id: account.id,
|
|
99
|
+
account_name: account.name,
|
|
95
100
|
};
|
|
96
101
|
}
|
|
97
102
|
export function webhookInfo(settings, lastError) {
|
|
@@ -111,16 +116,21 @@ export function webhookInfo(settings, lastError) {
|
|
|
111
116
|
/** Posts `message_received` when the webhook is on and valid. Never throws. */
|
|
112
117
|
export class WebhookSink {
|
|
113
118
|
env;
|
|
119
|
+
lastError = null;
|
|
114
120
|
post;
|
|
115
121
|
retryDelays;
|
|
116
|
-
|
|
117
|
-
constructor(env = process.env,
|
|
122
|
+
account;
|
|
123
|
+
constructor(env = process.env, opts = {}) {
|
|
118
124
|
this.env = env;
|
|
119
|
-
this.post = post;
|
|
120
|
-
this.retryDelays = retryDelays;
|
|
125
|
+
this.post = opts.post ?? fetch;
|
|
126
|
+
this.retryDelays = opts.retryDelays ?? WEBHOOK_RETRY_DELAYS_MS;
|
|
127
|
+
this.account = opts.account;
|
|
121
128
|
}
|
|
122
129
|
settings() {
|
|
123
|
-
return readWebhookSettings(this.env
|
|
130
|
+
return readWebhookSettings(this.env, {
|
|
131
|
+
url: this.account?.webhook_url,
|
|
132
|
+
secret: this.account?.webhook_secret,
|
|
133
|
+
});
|
|
124
134
|
}
|
|
125
135
|
info() {
|
|
126
136
|
return webhookInfo(this.settings(), this.lastError);
|
|
@@ -147,7 +157,7 @@ export class WebhookSink {
|
|
|
147
157
|
case "invalid":
|
|
148
158
|
return { ok: false, error: settings.detail, fix: settings.fix };
|
|
149
159
|
case "ready":
|
|
150
|
-
return this.postEvent(testPayload(), settings);
|
|
160
|
+
return this.postEvent(testPayload(this.account), settings);
|
|
151
161
|
default: {
|
|
152
162
|
const _exhaustive = settings;
|
|
153
163
|
return _exhaustive;
|
|
@@ -198,7 +208,7 @@ export class WebhookSink {
|
|
|
198
208
|
}
|
|
199
209
|
}
|
|
200
210
|
}
|
|
201
|
-
function testPayload() {
|
|
211
|
+
function testPayload(account) {
|
|
202
212
|
return {
|
|
203
213
|
event: WEBHOOK_EVENT,
|
|
204
214
|
from: "wazap",
|
|
@@ -206,6 +216,8 @@ function testPayload() {
|
|
|
206
216
|
ts: new Date().toISOString(),
|
|
207
217
|
text: "wazap webhook test",
|
|
208
218
|
message_id: "test",
|
|
219
|
+
account_id: account?.id ?? "default",
|
|
220
|
+
account_name: account?.name ?? "default",
|
|
209
221
|
};
|
|
210
222
|
}
|
|
211
223
|
function hostOf(url) {
|
package/dist/whatsapp.js
CHANGED
|
@@ -141,6 +141,8 @@ export class WhatsAppService {
|
|
|
141
141
|
* fix. The CLI turns it into an exit code, so a supervisor gets its turn.
|
|
142
142
|
*/
|
|
143
143
|
onGiveUp = null;
|
|
144
|
+
/** Fires when a `link` lands, so the hub can persist the owner on the record. */
|
|
145
|
+
onLinked = null;
|
|
144
146
|
account = null;
|
|
145
147
|
/** The pairing in flight, from the first `link` call until it settles either way. */
|
|
146
148
|
linking = null;
|
|
@@ -185,13 +187,14 @@ export class WhatsAppService {
|
|
|
185
187
|
transcribing = new Map();
|
|
186
188
|
drafts = new DraftStore();
|
|
187
189
|
writes;
|
|
188
|
-
webhook
|
|
190
|
+
webhook;
|
|
189
191
|
accountRecord;
|
|
190
192
|
effectiveReadOnly;
|
|
191
193
|
effectiveRateLimit;
|
|
192
194
|
constructor(config, account, paths) {
|
|
193
195
|
this.config = config;
|
|
194
196
|
this.accountRecord = account;
|
|
197
|
+
this.webhook = new WebhookSink(process.env, { account });
|
|
195
198
|
const policy = accountPolicy(account, config);
|
|
196
199
|
this.effectiveReadOnly = policy.readOnly;
|
|
197
200
|
this.effectiveRateLimit = policy.rateLimit;
|
|
@@ -319,6 +322,7 @@ export class WhatsAppService {
|
|
|
319
322
|
this.pairing = null;
|
|
320
323
|
this.account = account;
|
|
321
324
|
this.lastError = null;
|
|
325
|
+
this.onLinked?.(account);
|
|
322
326
|
await this.start();
|
|
323
327
|
}
|
|
324
328
|
abandonLink(err) {
|
|
@@ -2033,7 +2037,7 @@ export class WhatsAppService {
|
|
|
2033
2037
|
try {
|
|
2034
2038
|
const jid = this.canonical(raw.key.remoteJid ?? "");
|
|
2035
2039
|
const sid = messageIdFor(raw.key, jid);
|
|
2036
|
-
void this.webhook.notify(asWebhookPayload(this.viewOf(sid, jid))).catch((err) => {
|
|
2040
|
+
void this.webhook.notify(asWebhookPayload(this.viewOf(sid, jid), this.accountRecord)).catch((err) => {
|
|
2037
2041
|
logError("webhook", err);
|
|
2038
2042
|
});
|
|
2039
2043
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wazap-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"mcpName": "io.github.razvangirgiz/wazap",
|
|
5
|
-
"description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login,
|
|
5
|
+
"description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, several accounts, 33 tools, stdio or HTTP.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Răzvan Girgiz",
|
|
8
8
|
"repository": {
|