wazap-mcp 0.9.7 → 0.10.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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  ```
9
9
 
10
10
  **WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —
11
- chats, messages, media, contacts, groups — behind 23 tools any MCP client can
11
+ chats, messages, media, contacts, groups — behind 24 tools any MCP client can
12
12
  call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.
13
13
 
14
14
  Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the
@@ -82,7 +82,7 @@ it would write.
82
82
  | `gemini` | `~/.gemini/settings.json` |
83
83
  | `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
84
84
  | `opencode` | `mcp.whatsapp` in `~/.config/opencode/opencode.json` |
85
- | anything remote | client's MCP URL field: `https://your-host/mcp` with header `Authorization: Bearer <token>` (see [Self-host](#self-host)) |
85
+ | anything remote | client's MCP URL field: `https://your-host/mcp` with header `Authorization: Bearer <token>`, or just the URL once [OAuth](#hosted-agents-oauth) is on (see [Self-host](#self-host)) |
86
86
 
87
87
  ### Other MCP clients
88
88
 
@@ -189,6 +189,7 @@ manifest, the icon and a fresh production `node_modules`, then packs them with
189
189
  | `get_contact` | read | Name, number, about text, profile picture. |
190
190
  | `get_group_info` | read | Participants, admins, announcement mode, invite link (when you are admin). |
191
191
  | `download_media` | read | Save an attachment to disk; small images also come back inline. |
192
+ | `transcribe_audio` | read | Turn a voice note or audio message into text, with the local or the API provider. |
192
193
  | `send_message` | write | Send text, optionally as a reply, with @-mentions. |
193
194
  | `send_media` | write | Send an image, video, audio, voice note or document from a path or URL. |
194
195
  | `send_poll` | write | Send a poll with 2–12 options. |
@@ -202,10 +203,91 @@ manifest, the icon and a fresh production `node_modules`, then packs them with
202
203
  | `manage_group` | write | Add, remove, promote, demote, leave, rename, invite links. |
203
204
 
204
205
  Every message comes back with a non-empty `text`: media and system messages
205
- carry a placeholder such as `[image] caption`, `[voice message]`, `[deleted]` or
206
+ carry a placeholder such as `[image] caption`, `[voice message · 0:42]`, `[deleted]` or
206
207
  `[poll] Pizza or pasta?`. Timestamps are ISO 8601 with the machine's UTC offset,
207
208
  alongside a human `age` like `2h ago`.
208
209
 
210
+ ## Voice messages
211
+
212
+ A voice note is the one message an agent cannot read. Switch transcription on and
213
+ it becomes text: `[voice message · 0:42] "sunt la notar, ajung în 20 de minute"`,
214
+ with the bare words also in a `transcript` field. `get_recent_messages` and
215
+ `search_messages` see that text, so a voice note becomes findable by what was
216
+ said in it.
217
+
218
+ Pick a provider once, in `wazap setup` or later:
219
+
220
+ ```bash
221
+ wazap config transcribe local # free and private, one 574 MB model on disk
222
+ wazap config transcribe openai # cheap and fast, the audio leaves this machine
223
+ wazap config transcribe off
224
+ ```
225
+
226
+ | | `local` | `openai` |
227
+ | --- | --- | --- |
228
+ | Runs | whisper.cpp, here | any OpenAI-compatible `/audio/transcriptions` |
229
+ | Costs | nothing | per minute of audio, on your key |
230
+ | Privacy | the audio never leaves this machine | **the audio leaves this machine** |
231
+ | Needs | `whisper-cpp` and `ffmpeg`, plus a model | an API key |
232
+
233
+ ### Local, with whisper.cpp
234
+
235
+ ```bash
236
+ brew install whisper-cpp ffmpeg # macOS; elsewhere build whisper.cpp, install ffmpeg from your package manager
237
+ wazap transcribe download # fetch and verify the model
238
+ wazap transcribe test recording.ogg # prove it before you trust it
239
+ ```
240
+
241
+ Models land in `<data-dir>/models/` and are checked against a SHA-256 pinned in
242
+ the source; an interrupted download resumes where it stopped.
243
+
244
+ | `WAZAP_WHISPER_MODEL` | File | Size |
245
+ | --- | --- | --- |
246
+ | `turbo` (default) | `ggml-large-v3-turbo-q5_0.bin` | 574 MB |
247
+ | `large-v3` | `ggml-large-v3-q5_0.bin` | 1.08 GB |
248
+ | `medium` | `ggml-medium-q5_0.bin` | 539 MB |
249
+
250
+ `turbo` is the default because it is the smallest model that still gets Romanian
251
+ right. `medium` and below drop diacritics and mangle names, which is worse than
252
+ no transcript at all: a missing transcript is a question, a wrong name is a wrong
253
+ answer. `large-v3` is the same accuracy for several times the wait.
254
+
255
+ ### An API, OpenAI-compatible
256
+
257
+ `wazap config transcribe openai` asks for the key without echoing it and stores
258
+ it in `<data-dir>/.env`. The default endpoint is OpenAI; Groq works unchanged:
259
+
260
+ ```bash
261
+ WAZAP_TRANSCRIBE_URL=https://api.groq.com/openai/v1
262
+ WAZAP_TRANSCRIBE_MODEL=whisper-large-v3-turbo
263
+ ```
264
+
265
+ **With this provider the audio leaves your machine.** Every voice note wazap
266
+ transcribes is uploaded to that endpoint. If that is not acceptable, use `local`,
267
+ which uploads nothing.
268
+
269
+ The key is treated as a secret rather than as a setting:
270
+
271
+ - It is never accepted as a command-line argument, because an argument lands in
272
+ your shell history and in `ps`.
273
+ - The prompt echoes nothing, not even asterisks.
274
+ - It is stored only in `<data-dir>/.env`, mode `0600`.
275
+ - `status`, `status --json`, `config` and `get_status` show at most
276
+ `api key: set (…abcd)`.
277
+ - A provider's own error message has the key stripped out of it before wazap
278
+ prints it.
279
+ - A plain-`http` `WAZAP_TRANSCRIBE_URL` is refused unless it points back at this
280
+ machine.
281
+
282
+ ### Without being asked
283
+
284
+ With a provider configured, incoming voice notes of up to ten minutes are
285
+ transcribed in the background as they arrive, one at a time, never holding up a
286
+ message. The transcript is cached by message id and persisted, so a voice note is
287
+ transcribed once and not again after a restart. Audio *files* are left alone,
288
+ since one can be an hour long; call `transcribe_audio(message_id)` for those.
289
+ `WAZAP_TRANSCRIBE_AUTO=0` keeps the tool and stops the background work.
290
+
209
291
  ## Skills
210
292
 
211
293
  wazap ships five [Agent Skills](https://agentskills.io) that teach an agent the workflows behind the tools, not just the tools:
@@ -277,9 +359,11 @@ created `0700` with credentials written `0600`:
277
359
  auth/ WhatsApp credentials — treat this like a password
278
360
  media/ downloads from download_media
279
361
  history/ per-chat message history, so a restart is not amnesia
362
+ models/ whisper.cpp models, when transcription runs locally
280
363
  store.json chat-list snapshot
281
364
  server.lock pid of the running server
282
365
  daemon.json loopback endpoint a second wazap bridges to
366
+ oauth.json registered agents and hashed OAuth grants, when OAuth is on
283
367
  .env optional settings, see .env.example
284
368
  ```
285
369
 
@@ -330,7 +414,8 @@ npx wazap-mcp serve --http --host 0.0.0.0 --port 8766
330
414
  Streamable HTTP at `/mcp`, with a health check at `/healthz`. Two bearer tokens:
331
415
  the read token gets the read tools, the write token also unlocks the write
332
416
  tools, so a leaked read token can never message anyone. wazap refuses to bind a
333
- non-loopback address without a read token.
417
+ non-loopback address without a read token. Agents that cannot carry a header
418
+ sign in with [OAuth](#hosted-agents-oauth) instead.
334
419
 
335
420
  ## Self-host
336
421
 
@@ -365,9 +450,63 @@ curl -s http://127.0.0.1:8766/healthz
365
450
 
366
451
  The container publishes `8766` on loopback only; add the same TLS proxy in front. Upgrading is `git pull && docker compose up -d --build`; the volume keeps the session.
367
452
 
453
+ ### From a machine without a public address
454
+
455
+ A laptop or a box behind NAT can still serve hosted agents through a tunnel, with no port opened and TLS done at the edge. With [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) and a domain on Cloudflare:
456
+
457
+ ```bash
458
+ cloudflared tunnel login
459
+ cloudflared tunnel create wazap
460
+ cloudflared tunnel route dns wazap wazap.example.com
461
+ cloudflared tunnel run --url http://127.0.0.1:8766 wazap
462
+ ```
463
+
464
+ wazap keeps binding loopback; only the tunnel reaches it. Set `WAZAP_PUBLIC_URL=https://wazap.example.com` for OAuth and keep `cloudflared` running the way you keep wazap running (a systemd unit, a launchd agent). Tailscale Funnel or ngrok work the same way: whatever ends at `https://your-host` with `/mcp` behind it.
465
+
368
466
  ### Which clients can reach it
369
467
 
370
- Claude Code, Claude Desktop, Cursor, Codex, VS Code and any client with an "MCP URL + header" field connect with the bearer token. claude.ai Connectors require OAuth rather than a static token, so they cannot use a self-hosted wazap yet. Keep the read token in clients that only need to read; hand out the write token deliberately.
468
+ Claude Code, Claude Desktop, Cursor, Codex, VS Code, Poke and any client with an "MCP URL + header" field connect with the bearer token. Keep the read token in clients that only need to read; hand out the write token deliberately.
469
+
470
+ claude.ai Connectors, ChatGPT and some hosted agents will not take a static header. They want OAuth, which is the next section.
471
+
472
+ ### Hosted agents (OAuth)
473
+
474
+ Two more lines in the same `.env` turn wazap into its own OAuth 2.1 server:
475
+
476
+ ```bash
477
+ WAZAP_PUBLIC_URL=https://wazap.example.com
478
+ WAZAP_OAUTH_PASSWORD=$(openssl rand -base64 18)
479
+ ```
480
+
481
+ Then give an agent nothing but `https://wazap.example.com/mcp`. It finds the
482
+ authorization server at `/.well-known/oauth-protected-resource/mcp`, registers
483
+ itself (RFC 7591, so there is no client id to paste anywhere), and sends you to
484
+ a page on your own host that asks two things: the password above, and whether
485
+ this agent may only read or also send. A refresh token keeps the agent signed
486
+ in until you revoke it; access tokens rotate every 24 hours on their own.
487
+
488
+ Tested against the flow claude.ai, ChatGPT and Poke use: S256 PKCE, public
489
+ clients, `/token` with refresh, `/revoke`. The bearer tokens keep working next
490
+ to it, so a laptop client on a header and a hosted agent on OAuth share one
491
+ server.
492
+
493
+ What to know before exposing it:
494
+
495
+ - `WAZAP_PUBLIC_URL` must be `https` and a bare origin, no path: the
496
+ endpoints live at its root. The password travels to it.
497
+ - The password is the whole identity layer. Use a long one. A consent page
498
+ takes three wrong guesses and is gone; five from one address lock that
499
+ address out for fifteen minutes; twenty from anywhere close the page for
500
+ everyone for fifteen minutes.
501
+ - With OAuth on, `/mcp` never answers an unauthenticated request, whether or
502
+ not a read token is set.
503
+ - Grants live in `<data-dir>/oauth.json` as hashes. Delete the file to sign
504
+ every agent out at once, running server included; `wazap status` lists who
505
+ holds one. Disconnecting an agent on its side revokes its refresh token and
506
+ every access token it minted. A refresh token unused for ninety days is
507
+ dropped.
508
+ - A read grant never sees a write tool, whatever scope the agent requested.
509
+ The radio button on the consent page is the only thing that decides.
371
510
 
372
511
  ## Settings
373
512
 
@@ -381,7 +520,17 @@ Claude Code, Claude Desktop, Cursor, Codex, VS Code and any client with an "MCP
381
520
  | `WAZAP_TRANSPORT` | `stdio` | `stdio` or `http`. |
382
521
  | `WAZAP_HOST` / `WAZAP_PORT` | `127.0.0.1` / `8766` | HTTP bind address. |
383
522
  | `WAZAP_READ_TOKEN` / `WAZAP_WRITE_TOKEN` | unset | HTTP bearer tokens. |
523
+ | `WAZAP_PUBLIC_URL` | unset | The `https` address agents reach the server at. With the password, turns OAuth on. |
524
+ | `WAZAP_OAUTH_PASSWORD` | unset | What the consent page asks for. At least 8 characters. |
384
525
  | `WAZAP_NO_UPDATE_CHECK` | `0` | `1` stops `status` asking npm for a newer version. |
526
+ | `WAZAP_TRANSCRIBE` | `off` | `local`, `openai` or `off`. |
527
+ | `WAZAP_TRANSCRIBE_AUTO` | `1` | Transcribe incoming voice notes in the background. |
528
+ | `WAZAP_TRANSCRIBE_LANGUAGE` | `auto` | Spoken language, e.g. `ro`. |
529
+ | `WAZAP_WHISPER_MODEL` | `turbo` | `turbo`, `large-v3` or `medium`. |
530
+ | `WAZAP_WHISPER_BIN` | unset | Path to a whisper.cpp binary that is not on `PATH`. |
531
+ | `WAZAP_TRANSCRIBE_API_KEY` | unset | API key; `OPENAI_API_KEY` is the fallback. Never a flag. |
532
+ | `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
533
+ | `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |
385
534
 
386
535
  Flags beat environment variables, which beat `<data-dir>/.env`.
387
536
 
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { once } from "node:events";
2
3
  import { mkdirSync, rmSync } from "node:fs";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { setTimeout as sleep } from "node:timers/promises";
@@ -16,10 +17,12 @@ import { RELINK_FIX, WazapError, asWazapError } from "./errors.js";
16
17
  import { normalizePhone } from "./ids.js";
17
18
  import { lockHolder, releaseLock, writeLock } from "./lock.js";
18
19
  import { log, logError, say } from "./logger.js";
19
- import { formatAge } from "./messages.js";
20
+ import { clockLabel, formatAge } from "./messages.js";
20
21
  import { RateLimiter } from "./ratelimit.js";
22
+ import { oauthProblem } from "./oauth.js";
21
23
  import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
22
24
  import { applyWrites } from "./settings.js";
25
+ import { MODELS, downloadModel, maskKey, modelSpec, readTranscribeSettings, stripPasted, transcribeFile, transcribeReady, } from "./transcribe/index.js";
23
26
  import { bold, box, brand, humanLayout, dim, fail, fix, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
24
27
  import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
25
28
  const LOGIN_TIMEOUT_MS = 120_000;
@@ -241,6 +244,75 @@ export async function runContacts(config) {
241
244
  releaseLock(p.lockFile);
242
245
  }
243
246
  }
247
+ const MIB = 1024 * 1024;
248
+ function mib(bytes) {
249
+ return Math.round(bytes / MIB);
250
+ }
251
+ /** `wazap transcribe download` and `wazap transcribe test <audio file>`. */
252
+ export async function runTranscribe(config) {
253
+ const [verb, file] = config.args;
254
+ const settings = readTranscribeSettings(process.env, config.dataDir);
255
+ if (verb === "download" && file === undefined) {
256
+ await downloadTranscribeModel(settings, modelSpec(config.modelName ?? settings.model));
257
+ return;
258
+ }
259
+ if (verb === "test" && file !== undefined) {
260
+ await testTranscribe(settings, file);
261
+ return;
262
+ }
263
+ throw new WazapError("INVALID_ID", `Cannot run \`wazap transcribe ${config.args.join(" ")}\`.`, "Run `wazap transcribe download` or `wazap transcribe test <audio file>`");
264
+ }
265
+ /**
266
+ * Also `setup`'s download step. A model already on disk is re-hashed rather than
267
+ * trusted, which is why the line starts as a check and only then becomes a fetch.
268
+ */
269
+ export async function downloadTranscribeModel(settings, spec) {
270
+ const spin = spinner(`Checking ${spec.file}…`);
271
+ try {
272
+ const result = await downloadModel(settings.modelsDir, spec, (progress) => {
273
+ const percent = Math.floor((progress.received / progress.total) * 100);
274
+ spin.update(`Downloading ${spec.file} — ${mib(progress.received)} / ${mib(progress.total)} MiB (${percent}%)`);
275
+ });
276
+ spin.stop(ok(`${spec.file} (${mib(spec.bytes)} MiB) ${result.alreadyPresent ? "already present" : "verified"}`));
277
+ }
278
+ catch (err) {
279
+ spin.stop();
280
+ throw err;
281
+ }
282
+ }
283
+ /** What identifies each provider on screen. Keyed like PROVIDERS, never branched on. */
284
+ const PROVIDER_ROWS = {
285
+ local: (settings) => [
286
+ ["provider", "local (whisper.cpp)"],
287
+ ["model", MODELS[settings.model].file],
288
+ ],
289
+ openai: (settings) => [
290
+ ["provider", `openai (${new URL(settings.baseUrl).host})`],
291
+ ["model", settings.apiModel],
292
+ ["key", maskKey(settings.apiKey)],
293
+ ],
294
+ };
295
+ async function testTranscribe(settings, file) {
296
+ const readiness = await transcribeReady(settings);
297
+ const provider = settings.provider;
298
+ if (provider === null || !readiness.ok) {
299
+ throw new WazapError("TRANSCRIBE_UNAVAILABLE", readiness.detail, readiness.fix);
300
+ }
301
+ for (const [label, value] of PROVIDER_ROWS[provider](settings))
302
+ say(row(label, value));
303
+ say(row("language", settings.language));
304
+ say("");
305
+ const started = Date.now();
306
+ const spin = spinner(`Transcribing ${shortPath(file)}…`);
307
+ const transcript = await transcribeFile(settings, file).finally(() => spin.stop());
308
+ const facts = [
309
+ `transcribed in ${((Date.now() - started) / 1000).toFixed(1)}s`,
310
+ transcript.language,
311
+ transcript.duration_seconds === undefined ? undefined : clockLabel(transcript.duration_seconds),
312
+ ];
313
+ say(ok(facts.filter((fact) => fact !== undefined).join(" · ")));
314
+ say(`"${transcript.text}"`);
315
+ }
244
316
  /** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
245
317
  export async function runGreet(config) {
246
318
  say(banner());
@@ -279,6 +351,13 @@ export async function runServe(config) {
279
351
  say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
280
352
  process.exit(1);
281
353
  }
354
+ if (config.transport === "http") {
355
+ const problem = oauthProblem(config);
356
+ if (problem) {
357
+ say(fail(problem));
358
+ process.exit(1);
359
+ }
360
+ }
282
361
  mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
283
362
  if (writeLock(p.lockFile)) {
284
363
  claimed = true;
@@ -594,6 +673,27 @@ export async function ask(question) {
594
673
  rl.close();
595
674
  }
596
675
  }
676
+ /**
677
+ * A secret typed at the prompt, echoed nowhere: not as characters, not as stars.
678
+ * readline is given no output stream at all, which is the only mute that holds
679
+ * on current Node — overriding `_writeToOutput` no longer reaches the interface's
680
+ * own writer. `terminal` still follows the real stdin, so a TTY goes into raw
681
+ * mode (the kernel stops echoing too) and a pipe reads one line for a script.
682
+ */
683
+ export async function askSecret(question) {
684
+ const rl = createInterface({ input: process.stdin, output: undefined, terminal: process.stdin.isTTY === true });
685
+ process.stderr.write(question);
686
+ try {
687
+ // End of input answers nothing rather than hanging the command, and the
688
+ // caller refuses an empty secret.
689
+ const answered = rl.question("").catch(() => "");
690
+ return stripPasted(await Promise.race([answered, once(rl, "close").then(() => "")]));
691
+ }
692
+ finally {
693
+ rl.close();
694
+ process.stderr.write("\n");
695
+ }
696
+ }
597
697
  const PHONE_ATTEMPTS = 3;
598
698
  /** A typo costs another prompt, not the whole login. */
599
699
  async function askPhone() {
package/dist/config.js CHANGED
@@ -16,6 +16,7 @@ export function paths(dataDir) {
16
16
  storeFile: join(dataDir, "store.json"),
17
17
  lockFile: join(dataDir, "server.lock"),
18
18
  daemonFile: join(dataDir, "daemon.json"),
19
+ oauthFile: join(dataDir, "oauth.json"),
19
20
  envFile: join(dataDir, ".env"),
20
21
  qrFile: join(dataDir, "qr.png"),
21
22
  };
@@ -28,9 +29,13 @@ const COMMAND_ARGS = {
28
29
  status: [0],
29
30
  logout: [0],
30
31
  connect: [1],
31
- config: [0, 2],
32
+ // A third positional is only ever someone typing the API key after
33
+ // `config transcribe openai`. It is accepted here so runConfig can refuse it
34
+ // with the reason, rather than with a generic arity complaint.
35
+ config: [0, 2, 3],
32
36
  contacts: [1],
33
37
  skills: [2],
38
+ transcribe: [1, 2],
34
39
  };
35
40
  const COMMANDS = Object.keys(COMMAND_ARGS);
36
41
  export function defaultDataDir() {
@@ -86,6 +91,8 @@ export function parseCli(argv = process.argv.slice(2)) {
86
91
  "no-writes": { type: "boolean" },
87
92
  agent: { type: "boolean" },
88
93
  client: { type: "string", multiple: true },
94
+ model: { type: "string" },
95
+ transcribe: { type: "string" },
89
96
  yes: { type: "boolean", short: "y" },
90
97
  help: { type: "boolean", short: "h" },
91
98
  version: { type: "boolean", short: "v" },
@@ -134,6 +141,8 @@ export function parseCli(argv = process.argv.slice(2)) {
134
141
  httpPort: values.port ? asInt(values.port, 8766) : asInt(process.env.WAZAP_PORT, 8766),
135
142
  readToken: (process.env.WAZAP_READ_TOKEN ?? "").trim() || null,
136
143
  writeToken: (process.env.WAZAP_WRITE_TOKEN ?? "").trim() || null,
144
+ publicUrl: (process.env.WAZAP_PUBLIC_URL ?? "").trim().replace(/\/+$/, "") || null,
145
+ oauthPassword: process.env.WAZAP_OAUTH_PASSWORD || null,
137
146
  share: !asBool(process.env.WAZAP_NO_SHARE, false),
138
147
  rateLimitPerMinute: asInt(process.env.WAZAP_RATE_LIMIT, 20),
139
148
  sources: {
@@ -142,6 +151,7 @@ export function parseCli(argv = process.argv.slice(2)) {
142
151
  readOnly: sourceOf("WAZAP_READ_ONLY", values["read-only"] === true),
143
152
  transport: sourceOf("WAZAP_TRANSPORT", values.http === true),
144
153
  rateLimit: sourceOf("WAZAP_RATE_LIMIT", false),
154
+ transcribe: sourceOf("WAZAP_TRANSCRIBE", false),
145
155
  },
146
156
  command,
147
157
  explicitCommand: first !== undefined,
@@ -155,6 +165,8 @@ export function parseCli(argv = process.argv.slice(2)) {
155
165
  agent: values.agent === true,
156
166
  clients: values.client ?? [],
157
167
  assumeYes: values.yes === true,
168
+ modelName: values.model,
169
+ transcribeChoice: values.transcribe,
158
170
  },
159
171
  };
160
172
  }
package/dist/doctor.js CHANGED
@@ -1,18 +1,30 @@
1
1
  import { accessSync, constants, statSync } from "node:fs";
2
2
  import { readLinkedAccount } from "./auth-state.js";
3
3
  import { WAZAP_VERSION, paths } from "./config.js";
4
+ import { asWazapError } from "./errors.js";
4
5
  import { lockHolder, lockPid } from "./lock.js";
6
+ import { oauthProblem, readGrants } from "./oauth.js";
7
+ import { MODELS, findWhisper, localProvider, maskKey, modelPath, readTranscribeSettings, which, } from "./transcribe/index.js";
5
8
  import { dim, fail, fix, green, info, ok, red } from "./ui.js";
6
9
  export const MARK = { ok: "✓", fail: "✗", info: "–" };
7
10
  const GLYPH = { ok, fail, info };
8
11
  const TINT = { ok: green, fail: red, info: dim };
9
12
  const UPDATE_TIMEOUT_MS = 2_000;
10
13
  const MIN_NODE_MAJOR = 20;
11
- const CHECKS = [checkNode, checkDataDir, checkLock, checkCredentials, checkWrites, checkUpdate];
14
+ const CHECKS = [
15
+ checkNode,
16
+ checkDataDir,
17
+ checkLock,
18
+ checkCredentials,
19
+ checkWrites,
20
+ checkOAuth,
21
+ checkTranscribe,
22
+ checkUpdate,
23
+ ];
12
24
  export async function runChecks(config) {
13
25
  const checks = [];
14
26
  for (const check of CHECKS)
15
- checks.push(await check(config));
27
+ checks.push(...[await check(config)].flat());
16
28
  return checks;
17
29
  }
18
30
  /**
@@ -97,6 +109,85 @@ function checkWrites(config) {
97
109
  detail: `${config.readOnly ? "off" : "on"} (${config.sources.readOnly})`,
98
110
  };
99
111
  }
112
+ /** Only when OAuth is configured: whether it can start, and who is signed in. */
113
+ function checkOAuth(config) {
114
+ if (!config.publicUrl && !config.oauthPassword)
115
+ return [];
116
+ const problem = oauthProblem(config);
117
+ if (problem)
118
+ return [{ name: "oauth", state: "fail", detail: problem, fix: "edit <data-dir>/.env" }];
119
+ if (config.transport !== "http") {
120
+ return [{ name: "oauth", state: "info", detail: "configured, but only served with WAZAP_TRANSPORT=http" }];
121
+ }
122
+ const grants = readGrants(paths(config.dataDir).oauthFile);
123
+ if (grants.length === 0) {
124
+ return [{ name: "oauth", state: "info", detail: `on at ${config.publicUrl}, no agent signed in yet` }];
125
+ }
126
+ const who = grants.map((g) => `${g.client} (${g.scopes.join("+")})`).join(", ");
127
+ return [{ name: "oauth", state: "ok", detail: `on at ${config.publicUrl}; signed in: ${who}` }];
128
+ }
129
+ const TRANSCRIBE_OFF_FIX = "run `wazap config transcribe local` to transcribe voice messages";
130
+ const DOWNLOAD_FIX = "run `wazap transcribe download`";
131
+ const KEY_FIX = "run `wazap config transcribe openai`";
132
+ const MIB = 1024 * 1024;
133
+ /** What each provider needs before it can run. Keyed like PROVIDERS. */
134
+ const TRANSCRIBE_CHECKS = {
135
+ local: localChecks,
136
+ openai: openaiChecks,
137
+ };
138
+ async function checkTranscribe(config) {
139
+ let settings;
140
+ try {
141
+ settings = readTranscribeSettings(process.env, config.dataDir);
142
+ }
143
+ catch (err) {
144
+ // A stale WAZAP_TRANSCRIBE_URL or provider name in someone's .env is exactly
145
+ // what status is for, so the refusal is reported rather than thrown.
146
+ const failure = asWazapError(err);
147
+ return { name: "transcribe", state: "fail", detail: failure.message, fix: failure.fix };
148
+ }
149
+ if (settings.provider === null)
150
+ return { name: "transcribe", state: "info", detail: "off", fix: TRANSCRIBE_OFF_FIX };
151
+ return TRANSCRIBE_CHECKS[settings.provider](settings);
152
+ }
153
+ function fileSize(path) {
154
+ try {
155
+ return statSync(path).size;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ async function localChecks(settings) {
162
+ const whisper = findWhisper(settings);
163
+ const ffmpeg = which("ffmpeg");
164
+ const spec = MODELS[settings.model];
165
+ const size = fileSize(modelPath(settings.modelsDir, spec));
166
+ // ready() reports only the first problem and looks at the binaries before the
167
+ // model, so its fix is the platform's install hint whenever one is missing.
168
+ const install = (await localProvider.ready(settings)).fix;
169
+ return [
170
+ { name: "transcribe", state: "ok", detail: "local (whisper.cpp)" },
171
+ whisper === null
172
+ ? { name: "whisper", state: "fail", detail: "not found", fix: install }
173
+ : { name: "whisper", state: "ok", detail: whisper },
174
+ ffmpeg === null
175
+ ? { name: "ffmpeg", state: "fail", detail: "not found", fix: install }
176
+ : { name: "ffmpeg", state: "ok", detail: "found" },
177
+ size === null
178
+ ? { name: "model", state: "fail", detail: `${spec.file} is not downloaded`, fix: DOWNLOAD_FIX }
179
+ : { name: "model", state: "ok", detail: `${spec.file} (${Math.round(size / MIB)} MiB)` },
180
+ ];
181
+ }
182
+ /** maskKey is the only thing that ever renders the key, here and everywhere else. */
183
+ function openaiChecks(settings) {
184
+ return [
185
+ { name: "transcribe", state: "ok", detail: `openai (${settings.apiModel} at ${new URL(settings.baseUrl).host})` },
186
+ settings.apiKey === null
187
+ ? { name: "api key", state: "fail", detail: maskKey(null), fix: KEY_FIX }
188
+ : { name: "api key", state: "ok", detail: maskKey(settings.apiKey) },
189
+ ];
190
+ }
100
191
  /** Version comparison over the numeric release fields; prereleases sort as their release. */
101
192
  export function isNewer(candidate, current) {
102
193
  const parts = (version) => version.split(/[.\-+]/, 3).map((piece) => Number.parseInt(piece, 10) || 0);
package/dist/errors.js CHANGED
@@ -37,6 +37,8 @@ export const ERROR_GUIDE = {
37
37
  NOT_OWN_MESSAGE: "This action only works on messages the linked account sent. Do not retry.",
38
38
  READ_ONLY: "wazap runs read-only, so writes are refused. Tell the user to restart without WAZAP_READ_ONLY.",
39
39
  RATE_LIMITED: "Too many writes too fast. Wait the number of seconds in the fix, then retry once.",
40
+ TRANSCRIBE_UNAVAILABLE: "Transcription is off, or its binaries or model are missing. Tell the user to run the command in the fix; do not retry.",
41
+ TRANSCRIBE_FAILED: "The transcription provider ran and failed. Read the message; retry once at most.",
40
42
  TIMEOUT: "WhatsApp did not answer in time. Retry once; if it fails again, call get_status.",
41
43
  WHATSAPP_ERROR: "WhatsApp rejected the operation. Read the message; do not blindly retry.",
42
44
  };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { BANNER } from "./banner.js";
3
- import { runContacts, runGreet, runLogin, runLogout, runServe, runStatus } from "./cli.js";
3
+ import { runContacts, runGreet, runLogin, runLogout, runServe, runStatus, runTranscribe } from "./cli.js";
4
4
  import { WAZAP_VERSION, parseCli, pickDefaultAction } from "./config.js";
5
5
  import { CLIENT_NAMES, runConnect } from "./connect.js";
6
6
  import { SKILL_TARGET_NAMES, runSkills } from "./skills.js";
@@ -17,7 +17,10 @@ Usage:
17
17
  wazap setup [--agent] [--client <name>] Link, connect your client and finish, in one command
18
18
  wazap connect <client> [--dry-run] Register wazap with an MCP client
19
19
  wazap skills install <harness> [--dry-run] Copy the five skills into a harness
20
- wazap config [writes on|off] Show the effective settings, or allow/refuse writes
20
+ wazap config [writes on|off] [transcribe local|openai|off]
21
+ Show the effective settings, or change one
22
+ wazap transcribe download [--model <alias>] Fetch the whisper.cpp model into the data dir
23
+ wazap transcribe test <audio file> Transcribe a local file with the configured provider
21
24
  wazap contacts resync Fetch the phone's address book from WhatsApp again
22
25
  wazap status [--live] [--json] Check the install, the session and the server
23
26
  wazap logout Unlink and delete local credentials
@@ -35,6 +38,8 @@ Options:
35
38
  --phone <number> Your number in international format; implies --code
36
39
  --agent With setup: print the procedure for an AI agent on stdout, then exit
37
40
  --client <name> With setup: connect this client instead of the detected ones (repeatable)
41
+ --transcribe <how> With setup: answer the transcription question (local, openai or off)
42
+ --model <alias> With transcribe download: turbo (default), large-v3 or medium
38
43
  --dry-run With connect or skills install: print what would be written, and write nothing
39
44
  --live With status: reach WhatsApp for real, then close the connection
40
45
  --json With status: print the whole report as one JSON object on stdout
@@ -45,8 +50,11 @@ Options:
45
50
  -v, --version Show the version
46
51
 
47
52
  Environment: WAZAP_DATA_DIR, WAZAP_READ_ONLY, WAZAP_SYNC_FULL_HISTORY, WAZAP_PERSIST_HISTORY,
48
- WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WAZAP_RATE_LIMIT,
49
- WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK.
53
+ WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WAZAP_PUBLIC_URL,
54
+ WAZAP_OAUTH_PASSWORD, WAZAP_RATE_LIMIT,
55
+ WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK, WAZAP_TRANSCRIBE, WAZAP_TRANSCRIBE_AUTO,
56
+ WAZAP_TRANSCRIBE_LANGUAGE, WAZAP_TRANSCRIBE_API_KEY, WAZAP_TRANSCRIBE_URL, WAZAP_TRANSCRIBE_MODEL,
57
+ WAZAP_WHISPER_MODEL, WAZAP_WHISPER_BIN.
50
58
  An optional <data-dir>/.env is loaded if present.`;
51
59
  async function main() {
52
60
  const invocation = parseCli();
@@ -80,7 +88,10 @@ async function main() {
80
88
  runSkills(config);
81
89
  return;
82
90
  case "config":
83
- runConfig(config);
91
+ await runConfig(config);
92
+ return;
93
+ case "transcribe":
94
+ await runTranscribe(config);
84
95
  return;
85
96
  case "contacts":
86
97
  await runContacts(config);