wazap-mcp 0.9.6 → 0.9.8
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 +100 -3
- package/dist/calls.js +153 -0
- package/dist/cli.js +93 -1
- package/dist/config.js +10 -1
- package/dist/doctor.js +74 -2
- package/dist/errors.js +2 -0
- package/dist/index.js +14 -4
- package/dist/messages.js +136 -2
- package/dist/ratelimit.js +6 -2
- package/dist/settings.js +105 -8
- package/dist/setup.js +67 -3
- package/dist/tools.js +70 -5
- package/dist/transcribe/index.js +28 -0
- package/dist/transcribe/local.js +152 -0
- package/dist/transcribe/models.js +175 -0
- package/dist/transcribe/openai.js +103 -0
- package/dist/transcribe/queue.js +52 -0
- package/dist/transcribe/settings.js +92 -0
- package/dist/transcribe/types.js +2 -0
- package/dist/wa-types.js +19 -1
- package/dist/whatsapp.js +346 -30
- package/package.json +2 -2
- package/skills/whatsapp-groups/SKILL.md +1 -1
- package/skills/whatsapp-inbox/SKILL.md +26 -0
- package/skills/whatsapp-recall/SKILL.md +6 -1
package/README.md
CHANGED
|
@@ -180,8 +180,8 @@ manifest, the icon and a fresh production `node_modules`, then packs them with
|
|
|
180
180
|
| `learn` | read | The guide to every tool, id format and error code. Call it first. |
|
|
181
181
|
| `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |
|
|
182
182
|
| `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |
|
|
183
|
-
| `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone. |
|
|
184
|
-
| `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices. |
|
|
183
|
+
| `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone; `types` narrows to one or more message types, e.g. `["call"]`. |
|
|
184
|
+
| `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices, `types` narrows to one or more message types. |
|
|
185
185
|
| `search_messages` | read | Text search across the locally held messages. |
|
|
186
186
|
| `get_message` | read | One message in full, with its quoted message and reactions. |
|
|
187
187
|
| `search_contacts` | read | Find contacts by name or number. |
|
|
@@ -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,6 +359,7 @@ 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
|
|
@@ -382,6 +465,14 @@ Claude Code, Claude Desktop, Cursor, Codex, VS Code and any client with an "MCP
|
|
|
382
465
|
| `WAZAP_HOST` / `WAZAP_PORT` | `127.0.0.1` / `8766` | HTTP bind address. |
|
|
383
466
|
| `WAZAP_READ_TOKEN` / `WAZAP_WRITE_TOKEN` | unset | HTTP bearer tokens. |
|
|
384
467
|
| `WAZAP_NO_UPDATE_CHECK` | `0` | `1` stops `status` asking npm for a newer version. |
|
|
468
|
+
| `WAZAP_TRANSCRIBE` | `off` | `local`, `openai` or `off`. |
|
|
469
|
+
| `WAZAP_TRANSCRIBE_AUTO` | `1` | Transcribe incoming voice notes in the background. |
|
|
470
|
+
| `WAZAP_TRANSCRIBE_LANGUAGE` | `auto` | Spoken language, e.g. `ro`. |
|
|
471
|
+
| `WAZAP_WHISPER_MODEL` | `turbo` | `turbo`, `large-v3` or `medium`. |
|
|
472
|
+
| `WAZAP_WHISPER_BIN` | unset | Path to a whisper.cpp binary that is not on `PATH`. |
|
|
473
|
+
| `WAZAP_TRANSCRIBE_API_KEY` | unset | API key; `OPENAI_API_KEY` is the fallback. Never a flag. |
|
|
474
|
+
| `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
|
|
475
|
+
| `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |
|
|
385
476
|
|
|
386
477
|
Flags beat environment variables, which beat `<data-dir>/.env`.
|
|
387
478
|
|
|
@@ -404,6 +495,12 @@ Flags beat environment variables, which beat `<data-dir>/.env`.
|
|
|
404
495
|
state sync, and only to a connection asking for it from scratch. If contacts
|
|
405
496
|
read as phone numbers and `get_status` shows `contacts_named: 0`, ask for it
|
|
406
497
|
again with the `sync_contacts` tool or `wazap contacts resync`.
|
|
498
|
+
- **Calls are WhatsApp calls only.** A call shows up as a message with
|
|
499
|
+
`type: "call"`, carrying its kind, direction, outcome and duration. WhatsApp's
|
|
500
|
+
own call log and the missed-call notices arrive on their own; a call that
|
|
501
|
+
starts and ends while wazap is running is recorded live, so calls placed or
|
|
502
|
+
received while it is stopped can be missing entirely. A cellular call from the
|
|
503
|
+
phone's dialler is never visible, on any device.
|
|
407
504
|
- **Your phone must stay reachable.** A linked device stops receiving once the
|
|
408
505
|
phone has been offline long enough; `get_status` says so in `hint`.
|
|
409
506
|
|
package/dist/calls.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live calls. Baileys reports a call as a stream of status events and never as
|
|
3
|
+
* a message, so this folds that stream into one entry per call and hands back a
|
|
4
|
+
* synthetic WAMessage the ordinary store path can carry. Pure: no timers, no
|
|
5
|
+
* socket, no store, so a test can drive it by feeding events and a clock.
|
|
6
|
+
*/
|
|
7
|
+
import { proto } from "baileys";
|
|
8
|
+
/** A ringing call nobody answered and nobody hung up: the terminal event was lost. */
|
|
9
|
+
const RING_TIMEOUT_MS = 2 * 60_000;
|
|
10
|
+
/**
|
|
11
|
+
* An answered call is not expired at the ring timeout, which would invent a
|
|
12
|
+
* two-minute duration for a conversation still going on. It is only cut loose
|
|
13
|
+
* once it has run longer than any real call does.
|
|
14
|
+
*/
|
|
15
|
+
const ANSWERED_CAP_MS = 6 * 3_600_000;
|
|
16
|
+
/**
|
|
17
|
+
* How many settled call ids to remember. They are what makes a repeated
|
|
18
|
+
* terminal event store nothing twice, and the process is long-lived, so the set
|
|
19
|
+
* has to forget its oldest eventually rather than grow for the whole session.
|
|
20
|
+
*/
|
|
21
|
+
const SETTLED_MEMORY = 500;
|
|
22
|
+
/** Marks a stored message as one wazap tracked itself. See `isTrackedCall`. */
|
|
23
|
+
const TRACKED_ID_PREFIX = "call_";
|
|
24
|
+
const OUTCOME_CODES = {
|
|
25
|
+
answered: proto.Message.CallLogMessage.CallOutcome.CONNECTED,
|
|
26
|
+
rejected: proto.Message.CallLogMessage.CallOutcome.REJECTED,
|
|
27
|
+
missed: proto.Message.CallLogMessage.CallOutcome.MISSED,
|
|
28
|
+
unanswered: proto.Message.CallLogMessage.CallOutcome.MISSED,
|
|
29
|
+
};
|
|
30
|
+
function noAnswer(direction) {
|
|
31
|
+
return direction === "outgoing" ? "unanswered" : "missed";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `from` arrives as a LID as often as a phone jid, and either can carry a
|
|
35
|
+
* device suffix, so only the user part of the two jids is comparable.
|
|
36
|
+
*/
|
|
37
|
+
function samePerson(one, other) {
|
|
38
|
+
const user = (jid) => (jid.split("@")[0] ?? "").split(":")[0] ?? "";
|
|
39
|
+
const left = user(one);
|
|
40
|
+
return left.length > 0 && left === user(other);
|
|
41
|
+
}
|
|
42
|
+
export class CallTracker {
|
|
43
|
+
calls = new Map();
|
|
44
|
+
settled = new Set();
|
|
45
|
+
get pending() {
|
|
46
|
+
return this.calls.size;
|
|
47
|
+
}
|
|
48
|
+
/** The entry to store once the call reaches a terminal state, else null. */
|
|
49
|
+
observe(event, ownJid, now) {
|
|
50
|
+
if (!event.id || this.settled.has(event.id))
|
|
51
|
+
return null;
|
|
52
|
+
const call = this.calls.get(event.id) ?? this.begin(event, ownJid, now);
|
|
53
|
+
call.lastSeen = now;
|
|
54
|
+
switch (event.status) {
|
|
55
|
+
case "accept":
|
|
56
|
+
call.acceptedAt = now;
|
|
57
|
+
return null;
|
|
58
|
+
case "reject":
|
|
59
|
+
return this.finish(call, "rejected");
|
|
60
|
+
case "timeout":
|
|
61
|
+
return this.finish(call, noAnswer(call.direction));
|
|
62
|
+
case "terminate":
|
|
63
|
+
return call.acceptedAt === undefined
|
|
64
|
+
? this.finish(call, noAnswer(call.direction))
|
|
65
|
+
: this.finish(call, "answered", Math.round((now - call.acceptedAt) / 1000));
|
|
66
|
+
default:
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Entries for calls whose terminal event never arrived. */
|
|
71
|
+
expire(now) {
|
|
72
|
+
const done = [];
|
|
73
|
+
for (const call of [...this.calls.values()]) {
|
|
74
|
+
if (call.acceptedAt === undefined) {
|
|
75
|
+
if (now - call.lastSeen >= RING_TIMEOUT_MS)
|
|
76
|
+
done.push(this.finish(call, noAnswer(call.direction)));
|
|
77
|
+
}
|
|
78
|
+
else if (now - call.acceptedAt >= ANSWERED_CAP_MS) {
|
|
79
|
+
done.push(this.finish(call, "answered", Math.round((now - call.acceptedAt) / 1000)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return done;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* An event for an unknown call-id starts a pending call from whatever it
|
|
86
|
+
* carries, so a restart in the middle of one still records something. Only
|
|
87
|
+
* the offer names isVideo and the group, which is why baileys replays them
|
|
88
|
+
* from its own cache onto the later events of the same call.
|
|
89
|
+
*/
|
|
90
|
+
begin(event, ownJid, now) {
|
|
91
|
+
const offered = event.date instanceof Date ? event.date.getTime() : Number.NaN;
|
|
92
|
+
const chat = (event.isGroup ? (event.groupJid ?? event.chatId) : event.chatId) || event.from;
|
|
93
|
+
const call = {
|
|
94
|
+
callId: event.id,
|
|
95
|
+
chatId: chat,
|
|
96
|
+
at: Number.isFinite(offered) ? offered : now,
|
|
97
|
+
kind: event.isVideo ? "video" : "voice",
|
|
98
|
+
direction: samePerson(event.from, ownJid) ? "outgoing" : "incoming",
|
|
99
|
+
lastSeen: now,
|
|
100
|
+
};
|
|
101
|
+
this.calls.set(event.id, call);
|
|
102
|
+
return call;
|
|
103
|
+
}
|
|
104
|
+
finish(call, outcome, durationSeconds) {
|
|
105
|
+
this.calls.delete(call.callId);
|
|
106
|
+
this.settled.add(call.callId);
|
|
107
|
+
if (this.settled.size > SETTLED_MEMORY) {
|
|
108
|
+
const oldest = this.settled.values().next().value;
|
|
109
|
+
if (oldest !== undefined)
|
|
110
|
+
this.settled.delete(oldest);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
callId: call.callId,
|
|
114
|
+
chatId: call.chatId,
|
|
115
|
+
at: call.at,
|
|
116
|
+
kind: call.kind,
|
|
117
|
+
direction: call.direction,
|
|
118
|
+
outcome,
|
|
119
|
+
...(durationSeconds === undefined ? {} : { durationSeconds }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* True for a message this tracker built. The tracker emits one entry per call
|
|
125
|
+
* id, so two of these are always two different calls however close together
|
|
126
|
+
* they fall, which is the one thing a dedupe by timestamp cannot know.
|
|
127
|
+
*/
|
|
128
|
+
export function isTrackedCall(raw) {
|
|
129
|
+
return (raw.key?.id ?? "").startsWith(TRACKED_ID_PREFIX);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The entry as WhatsApp would have logged it, so snapshot, history JSONL, views
|
|
133
|
+
* and list_chats all carry a live call with no machinery of their own. The
|
|
134
|
+
* fields have to survive an encode/decode round trip, because that is what
|
|
135
|
+
* persistence does to it.
|
|
136
|
+
*/
|
|
137
|
+
export function callMessage(entry) {
|
|
138
|
+
return {
|
|
139
|
+
key: {
|
|
140
|
+
remoteJid: entry.chatId,
|
|
141
|
+
fromMe: entry.direction === "outgoing",
|
|
142
|
+
id: `${TRACKED_ID_PREFIX}${entry.callId}`,
|
|
143
|
+
},
|
|
144
|
+
messageTimestamp: Math.floor(entry.at / 1000),
|
|
145
|
+
message: {
|
|
146
|
+
callLogMesssage: {
|
|
147
|
+
isVideo: entry.kind === "video",
|
|
148
|
+
callOutcome: OUTCOME_CODES[entry.outcome],
|
|
149
|
+
...(entry.durationSeconds === undefined ? {} : { durationSecs: entry.durationSeconds }),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
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,11 @@ 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";
|
|
21
22
|
import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
|
|
22
23
|
import { applyWrites } from "./settings.js";
|
|
24
|
+
import { MODELS, downloadModel, maskKey, modelSpec, readTranscribeSettings, stripPasted, transcribeFile, transcribeReady, } from "./transcribe/index.js";
|
|
23
25
|
import { bold, box, brand, humanLayout, dim, fail, fix, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
|
|
24
26
|
import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
|
|
25
27
|
const LOGIN_TIMEOUT_MS = 120_000;
|
|
@@ -241,6 +243,75 @@ export async function runContacts(config) {
|
|
|
241
243
|
releaseLock(p.lockFile);
|
|
242
244
|
}
|
|
243
245
|
}
|
|
246
|
+
const MIB = 1024 * 1024;
|
|
247
|
+
function mib(bytes) {
|
|
248
|
+
return Math.round(bytes / MIB);
|
|
249
|
+
}
|
|
250
|
+
/** `wazap transcribe download` and `wazap transcribe test <audio file>`. */
|
|
251
|
+
export async function runTranscribe(config) {
|
|
252
|
+
const [verb, file] = config.args;
|
|
253
|
+
const settings = readTranscribeSettings(process.env, config.dataDir);
|
|
254
|
+
if (verb === "download" && file === undefined) {
|
|
255
|
+
await downloadTranscribeModel(settings, modelSpec(config.modelName ?? settings.model));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (verb === "test" && file !== undefined) {
|
|
259
|
+
await testTranscribe(settings, file);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
throw new WazapError("INVALID_ID", `Cannot run \`wazap transcribe ${config.args.join(" ")}\`.`, "Run `wazap transcribe download` or `wazap transcribe test <audio file>`");
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Also `setup`'s download step. A model already on disk is re-hashed rather than
|
|
266
|
+
* trusted, which is why the line starts as a check and only then becomes a fetch.
|
|
267
|
+
*/
|
|
268
|
+
export async function downloadTranscribeModel(settings, spec) {
|
|
269
|
+
const spin = spinner(`Checking ${spec.file}…`);
|
|
270
|
+
try {
|
|
271
|
+
const result = await downloadModel(settings.modelsDir, spec, (progress) => {
|
|
272
|
+
const percent = Math.floor((progress.received / progress.total) * 100);
|
|
273
|
+
spin.update(`Downloading ${spec.file} — ${mib(progress.received)} / ${mib(progress.total)} MiB (${percent}%)`);
|
|
274
|
+
});
|
|
275
|
+
spin.stop(ok(`${spec.file} (${mib(spec.bytes)} MiB) ${result.alreadyPresent ? "already present" : "verified"}`));
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
spin.stop();
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** What identifies each provider on screen. Keyed like PROVIDERS, never branched on. */
|
|
283
|
+
const PROVIDER_ROWS = {
|
|
284
|
+
local: (settings) => [
|
|
285
|
+
["provider", "local (whisper.cpp)"],
|
|
286
|
+
["model", MODELS[settings.model].file],
|
|
287
|
+
],
|
|
288
|
+
openai: (settings) => [
|
|
289
|
+
["provider", `openai (${new URL(settings.baseUrl).host})`],
|
|
290
|
+
["model", settings.apiModel],
|
|
291
|
+
["key", maskKey(settings.apiKey)],
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
async function testTranscribe(settings, file) {
|
|
295
|
+
const readiness = await transcribeReady(settings);
|
|
296
|
+
const provider = settings.provider;
|
|
297
|
+
if (provider === null || !readiness.ok) {
|
|
298
|
+
throw new WazapError("TRANSCRIBE_UNAVAILABLE", readiness.detail, readiness.fix);
|
|
299
|
+
}
|
|
300
|
+
for (const [label, value] of PROVIDER_ROWS[provider](settings))
|
|
301
|
+
say(row(label, value));
|
|
302
|
+
say(row("language", settings.language));
|
|
303
|
+
say("");
|
|
304
|
+
const started = Date.now();
|
|
305
|
+
const spin = spinner(`Transcribing ${shortPath(file)}…`);
|
|
306
|
+
const transcript = await transcribeFile(settings, file).finally(() => spin.stop());
|
|
307
|
+
const facts = [
|
|
308
|
+
`transcribed in ${((Date.now() - started) / 1000).toFixed(1)}s`,
|
|
309
|
+
transcript.language,
|
|
310
|
+
transcript.duration_seconds === undefined ? undefined : clockLabel(transcript.duration_seconds),
|
|
311
|
+
];
|
|
312
|
+
say(ok(facts.filter((fact) => fact !== undefined).join(" · ")));
|
|
313
|
+
say(`"${transcript.text}"`);
|
|
314
|
+
}
|
|
244
315
|
/** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
|
|
245
316
|
export async function runGreet(config) {
|
|
246
317
|
say(banner());
|
|
@@ -594,6 +665,27 @@ export async function ask(question) {
|
|
|
594
665
|
rl.close();
|
|
595
666
|
}
|
|
596
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* A secret typed at the prompt, echoed nowhere: not as characters, not as stars.
|
|
670
|
+
* readline is given no output stream at all, which is the only mute that holds
|
|
671
|
+
* on current Node — overriding `_writeToOutput` no longer reaches the interface's
|
|
672
|
+
* own writer. `terminal` still follows the real stdin, so a TTY goes into raw
|
|
673
|
+
* mode (the kernel stops echoing too) and a pipe reads one line for a script.
|
|
674
|
+
*/
|
|
675
|
+
export async function askSecret(question) {
|
|
676
|
+
const rl = createInterface({ input: process.stdin, output: undefined, terminal: process.stdin.isTTY === true });
|
|
677
|
+
process.stderr.write(question);
|
|
678
|
+
try {
|
|
679
|
+
// End of input answers nothing rather than hanging the command, and the
|
|
680
|
+
// caller refuses an empty secret.
|
|
681
|
+
const answered = rl.question("").catch(() => "");
|
|
682
|
+
return stripPasted(await Promise.race([answered, once(rl, "close").then(() => "")]));
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
rl.close();
|
|
686
|
+
process.stderr.write("\n");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
597
689
|
const PHONE_ATTEMPTS = 3;
|
|
598
690
|
/** A typo costs another prompt, not the whole login. */
|
|
599
691
|
async function askPhone() {
|
package/dist/config.js
CHANGED
|
@@ -28,9 +28,13 @@ const COMMAND_ARGS = {
|
|
|
28
28
|
status: [0],
|
|
29
29
|
logout: [0],
|
|
30
30
|
connect: [1],
|
|
31
|
-
|
|
31
|
+
// A third positional is only ever someone typing the API key after
|
|
32
|
+
// `config transcribe openai`. It is accepted here so runConfig can refuse it
|
|
33
|
+
// with the reason, rather than with a generic arity complaint.
|
|
34
|
+
config: [0, 2, 3],
|
|
32
35
|
contacts: [1],
|
|
33
36
|
skills: [2],
|
|
37
|
+
transcribe: [1, 2],
|
|
34
38
|
};
|
|
35
39
|
const COMMANDS = Object.keys(COMMAND_ARGS);
|
|
36
40
|
export function defaultDataDir() {
|
|
@@ -86,6 +90,8 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
86
90
|
"no-writes": { type: "boolean" },
|
|
87
91
|
agent: { type: "boolean" },
|
|
88
92
|
client: { type: "string", multiple: true },
|
|
93
|
+
model: { type: "string" },
|
|
94
|
+
transcribe: { type: "string" },
|
|
89
95
|
yes: { type: "boolean", short: "y" },
|
|
90
96
|
help: { type: "boolean", short: "h" },
|
|
91
97
|
version: { type: "boolean", short: "v" },
|
|
@@ -142,6 +148,7 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
142
148
|
readOnly: sourceOf("WAZAP_READ_ONLY", values["read-only"] === true),
|
|
143
149
|
transport: sourceOf("WAZAP_TRANSPORT", values.http === true),
|
|
144
150
|
rateLimit: sourceOf("WAZAP_RATE_LIMIT", false),
|
|
151
|
+
transcribe: sourceOf("WAZAP_TRANSCRIBE", false),
|
|
145
152
|
},
|
|
146
153
|
command,
|
|
147
154
|
explicitCommand: first !== undefined,
|
|
@@ -155,6 +162,8 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
155
162
|
agent: values.agent === true,
|
|
156
163
|
clients: values.client ?? [],
|
|
157
164
|
assumeYes: values.yes === true,
|
|
165
|
+
modelName: values.model,
|
|
166
|
+
transcribeChoice: values.transcribe,
|
|
158
167
|
},
|
|
159
168
|
};
|
|
160
169
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
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 { MODELS, findWhisper, localProvider, maskKey, modelPath, readTranscribeSettings, which, } from "./transcribe/index.js";
|
|
5
7
|
import { dim, fail, fix, green, info, ok, red } from "./ui.js";
|
|
6
8
|
export const MARK = { ok: "✓", fail: "✗", info: "–" };
|
|
7
9
|
const GLYPH = { ok, fail, info };
|
|
8
10
|
const TINT = { ok: green, fail: red, info: dim };
|
|
9
11
|
const UPDATE_TIMEOUT_MS = 2_000;
|
|
10
12
|
const MIN_NODE_MAJOR = 20;
|
|
11
|
-
const CHECKS = [
|
|
13
|
+
const CHECKS = [
|
|
14
|
+
checkNode,
|
|
15
|
+
checkDataDir,
|
|
16
|
+
checkLock,
|
|
17
|
+
checkCredentials,
|
|
18
|
+
checkWrites,
|
|
19
|
+
checkTranscribe,
|
|
20
|
+
checkUpdate,
|
|
21
|
+
];
|
|
12
22
|
export async function runChecks(config) {
|
|
13
23
|
const checks = [];
|
|
14
24
|
for (const check of CHECKS)
|
|
15
|
-
checks.push(await check(config));
|
|
25
|
+
checks.push(...[await check(config)].flat());
|
|
16
26
|
return checks;
|
|
17
27
|
}
|
|
18
28
|
/**
|
|
@@ -97,6 +107,68 @@ function checkWrites(config) {
|
|
|
97
107
|
detail: `${config.readOnly ? "off" : "on"} (${config.sources.readOnly})`,
|
|
98
108
|
};
|
|
99
109
|
}
|
|
110
|
+
const TRANSCRIBE_OFF_FIX = "run `wazap config transcribe local` to transcribe voice messages";
|
|
111
|
+
const DOWNLOAD_FIX = "run `wazap transcribe download`";
|
|
112
|
+
const KEY_FIX = "run `wazap config transcribe openai`";
|
|
113
|
+
const MIB = 1024 * 1024;
|
|
114
|
+
/** What each provider needs before it can run. Keyed like PROVIDERS. */
|
|
115
|
+
const TRANSCRIBE_CHECKS = {
|
|
116
|
+
local: localChecks,
|
|
117
|
+
openai: openaiChecks,
|
|
118
|
+
};
|
|
119
|
+
async function checkTranscribe(config) {
|
|
120
|
+
let settings;
|
|
121
|
+
try {
|
|
122
|
+
settings = readTranscribeSettings(process.env, config.dataDir);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// A stale WAZAP_TRANSCRIBE_URL or provider name in someone's .env is exactly
|
|
126
|
+
// what status is for, so the refusal is reported rather than thrown.
|
|
127
|
+
const failure = asWazapError(err);
|
|
128
|
+
return { name: "transcribe", state: "fail", detail: failure.message, fix: failure.fix };
|
|
129
|
+
}
|
|
130
|
+
if (settings.provider === null)
|
|
131
|
+
return { name: "transcribe", state: "info", detail: "off", fix: TRANSCRIBE_OFF_FIX };
|
|
132
|
+
return TRANSCRIBE_CHECKS[settings.provider](settings);
|
|
133
|
+
}
|
|
134
|
+
function fileSize(path) {
|
|
135
|
+
try {
|
|
136
|
+
return statSync(path).size;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function localChecks(settings) {
|
|
143
|
+
const whisper = findWhisper(settings);
|
|
144
|
+
const ffmpeg = which("ffmpeg");
|
|
145
|
+
const spec = MODELS[settings.model];
|
|
146
|
+
const size = fileSize(modelPath(settings.modelsDir, spec));
|
|
147
|
+
// ready() reports only the first problem and looks at the binaries before the
|
|
148
|
+
// model, so its fix is the platform's install hint whenever one is missing.
|
|
149
|
+
const install = (await localProvider.ready(settings)).fix;
|
|
150
|
+
return [
|
|
151
|
+
{ name: "transcribe", state: "ok", detail: "local (whisper.cpp)" },
|
|
152
|
+
whisper === null
|
|
153
|
+
? { name: "whisper", state: "fail", detail: "not found", fix: install }
|
|
154
|
+
: { name: "whisper", state: "ok", detail: whisper },
|
|
155
|
+
ffmpeg === null
|
|
156
|
+
? { name: "ffmpeg", state: "fail", detail: "not found", fix: install }
|
|
157
|
+
: { name: "ffmpeg", state: "ok", detail: "found" },
|
|
158
|
+
size === null
|
|
159
|
+
? { name: "model", state: "fail", detail: `${spec.file} is not downloaded`, fix: DOWNLOAD_FIX }
|
|
160
|
+
: { name: "model", state: "ok", detail: `${spec.file} (${Math.round(size / MIB)} MiB)` },
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
/** maskKey is the only thing that ever renders the key, here and everywhere else. */
|
|
164
|
+
function openaiChecks(settings) {
|
|
165
|
+
return [
|
|
166
|
+
{ name: "transcribe", state: "ok", detail: `openai (${settings.apiModel} at ${new URL(settings.baseUrl).host})` },
|
|
167
|
+
settings.apiKey === null
|
|
168
|
+
? { name: "api key", state: "fail", detail: maskKey(null), fix: KEY_FIX }
|
|
169
|
+
: { name: "api key", state: "ok", detail: maskKey(settings.apiKey) },
|
|
170
|
+
];
|
|
171
|
+
}
|
|
100
172
|
/** Version comparison over the numeric release fields; prereleases sort as their release. */
|
|
101
173
|
export function isNewer(candidate, current) {
|
|
102
174
|
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]
|
|
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
|
|
@@ -46,7 +51,9 @@ Options:
|
|
|
46
51
|
|
|
47
52
|
Environment: WAZAP_DATA_DIR, WAZAP_READ_ONLY, WAZAP_SYNC_FULL_HISTORY, WAZAP_PERSIST_HISTORY,
|
|
48
53
|
WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WAZAP_RATE_LIMIT,
|
|
49
|
-
WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK
|
|
54
|
+
WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK, WAZAP_TRANSCRIBE, WAZAP_TRANSCRIBE_AUTO,
|
|
55
|
+
WAZAP_TRANSCRIBE_LANGUAGE, WAZAP_TRANSCRIBE_API_KEY, WAZAP_TRANSCRIBE_URL, WAZAP_TRANSCRIBE_MODEL,
|
|
56
|
+
WAZAP_WHISPER_MODEL, WAZAP_WHISPER_BIN.
|
|
50
57
|
An optional <data-dir>/.env is loaded if present.`;
|
|
51
58
|
async function main() {
|
|
52
59
|
const invocation = parseCli();
|
|
@@ -80,7 +87,10 @@ async function main() {
|
|
|
80
87
|
runSkills(config);
|
|
81
88
|
return;
|
|
82
89
|
case "config":
|
|
83
|
-
runConfig(config);
|
|
90
|
+
await runConfig(config);
|
|
91
|
+
return;
|
|
92
|
+
case "transcribe":
|
|
93
|
+
await runTranscribe(config);
|
|
84
94
|
return;
|
|
85
95
|
case "contacts":
|
|
86
96
|
await runContacts(config);
|