wazap-mcp 0.17.0 → 0.18.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/dist/account-cli.js +1 -1
- package/dist/account-resolve.js +3 -1
- package/dist/cli.js +37 -4
- package/dist/compact.js +15 -2
- package/dist/config.js +4 -1
- package/dist/connect.js +26 -6
- package/dist/deps.js +1 -0
- package/dist/doctor.js +56 -6
- package/dist/drafts.js +4 -1
- package/dist/errors.js +3 -0
- package/dist/gif.js +12 -5
- package/dist/ids.js +4 -1
- package/dist/index.js +20 -5
- package/dist/messages.js +41 -3
- package/dist/notes.js +20 -3
- package/dist/oauth.js +21 -6
- package/dist/outgoing-media.js +8 -22
- package/dist/pairing.js +2 -2
- package/dist/previews.js +20 -6
- package/dist/recall/engine.js +268 -0
- package/dist/recall/index.js +6 -0
- package/dist/recall/models.js +68 -0
- package/dist/recall/queue.js +183 -0
- package/dist/recall/settings.js +63 -0
- package/dist/recall/store.js +422 -0
- package/dist/recall/types.js +2 -0
- package/dist/safe-media.js +128 -0
- package/dist/settings.js +35 -4
- package/dist/setup.js +32 -5
- package/dist/skills.js +3 -1
- package/dist/store.js +4 -1
- package/dist/tools.js +166 -19
- package/dist/transcribe/index.js +1 -1
- package/dist/transcribe/local.js +15 -1
- package/dist/ui.js +1 -0
- package/dist/webhook.js +9 -2
- package/dist/whatsapp.js +313 -11
- package/dist/wizard.js +3 -0
- package/package.json +12 -3
- package/skills/whatsapp-recall/SKILL.md +2 -2
package/dist/account-cli.js
CHANGED
|
@@ -38,7 +38,7 @@ export function describeStatusAccount(row) {
|
|
|
38
38
|
export function accountRows(config) {
|
|
39
39
|
const registry = AccountRegistry.load(config.dataDir);
|
|
40
40
|
return registry.all().map((record) => {
|
|
41
|
-
let linked
|
|
41
|
+
let linked;
|
|
42
42
|
try {
|
|
43
43
|
linked = readLinkedAccount(accountPaths(config.dataDir, record.id).authDir);
|
|
44
44
|
}
|
package/dist/account-resolve.js
CHANGED
|
@@ -136,7 +136,9 @@ export function renderGetStatus(s, writeTools, hub) {
|
|
|
136
136
|
`- **contacts named**: ${s.contacts_named}`,
|
|
137
137
|
`- **data dir**: ${s.data_dir} · **read-only**: ${s.read_only} · **write tools**: ${writeLine} · **rate limit**: ${s.rate_limit}/min`,
|
|
138
138
|
`- **versions**: wazap ${s.wazap_version}, baileys ${s.baileys_version}`,
|
|
139
|
-
s.pairing
|
|
139
|
+
s.pairing
|
|
140
|
+
? `- **pairing code**: ${s.pairing.code} for ${s.pairing.phone_masked}, until ${s.pairing.expires_at}`
|
|
141
|
+
: null,
|
|
140
142
|
webhookStatusLine(s.webhook),
|
|
141
143
|
s.last_error ? `- **last error**: ${s.last_error}` : null,
|
|
142
144
|
s.hint ? `- **hint**: ${s.hint}` : null,
|
package/dist/cli.js
CHANGED
|
@@ -23,13 +23,14 @@ import { lockHolder, releaseLock, writeLock } from "./lock.js";
|
|
|
23
23
|
import { log, logError, say } from "./logger.js";
|
|
24
24
|
import { clockLabel, formatAge } from "./messages.js";
|
|
25
25
|
import { oauthProblem } from "./oauth.js";
|
|
26
|
+
import { downloadEmbed, embedModelSpec, readRecallSettings, } from "./recall/index.js";
|
|
26
27
|
import { PAIRING_TIMEOUT_MS, linkSession, prettyCode, settledAccount, startPairing } from "./pairing.js";
|
|
27
28
|
import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
|
|
28
29
|
import { fetchHealth, serviceHolding } from "./service.js";
|
|
29
30
|
import { applyWrites } from "./settings.js";
|
|
30
31
|
import { MODELS, downloadModel, maskKey, modelSpec, readTranscribeSettings, stripPasted, transcribeFile, transcribeReady, } from "./transcribe/index.js";
|
|
31
32
|
import { bold, box, brand, humanLayout, dim, fail, fix, info, maskNumber, next, ok, openScreen, qrSavedLine, shortPath, spinner, tilde, warn, } from "./ui.js";
|
|
32
|
-
import { loginWizardSteps, maybeWizard, wizDim, wizFail, wizInfo, wizOk, wizWarn
|
|
33
|
+
import { loginWizardSteps, maybeWizard, wizDim, wizFail, wizInfo, wizOk, wizWarn } from "./wizard.js";
|
|
33
34
|
import { WhatsAppService } from "./whatsapp.js";
|
|
34
35
|
/** How long a probe waits for the socket to settle; tests shorten it through the environment. */
|
|
35
36
|
const LIVE_TIMEOUT_MS = Number.parseInt(process.env.WAZAP_LIVE_TIMEOUT_MS ?? "", 10) || 15_000;
|
|
@@ -106,7 +107,9 @@ export async function runStatus(config) {
|
|
|
106
107
|
/** Today's phrasing, kept verbatim so pipes and log captures keep parsing. */
|
|
107
108
|
function plainStatus(report) {
|
|
108
109
|
const lines = [`data dir: ${report.data_dir}`];
|
|
109
|
-
const credsNote = report.credentials_readable
|
|
110
|
+
const credsNote = report.credentials_readable
|
|
111
|
+
? ""
|
|
112
|
+
: " (credentials unreadable — run `wazap logout` then `wazap login`)";
|
|
110
113
|
lines.push(`linked: ${report.linked ? "yes" : "no"}${credsNote}`);
|
|
111
114
|
if (report.account)
|
|
112
115
|
lines.push(`account: ${describeAccount(report.account)}`);
|
|
@@ -326,6 +329,32 @@ export async function downloadTranscribeModel(settings, spec) {
|
|
|
326
329
|
throw err;
|
|
327
330
|
}
|
|
328
331
|
}
|
|
332
|
+
/** `wazap embed download`. */
|
|
333
|
+
export async function runEmbed(config) {
|
|
334
|
+
const [verb] = config.args;
|
|
335
|
+
if (verb !== "download") {
|
|
336
|
+
throw new WazapError("INVALID_ID", `Cannot run \`wazap embed ${config.args.join(" ")}\`.`, "Run `wazap embed download`");
|
|
337
|
+
}
|
|
338
|
+
await ensureDeps([DEPS.llama], config);
|
|
339
|
+
const settings = readRecallSettings(process.env, config.dataDir);
|
|
340
|
+
await downloadEmbedModel(settings, config.modelName);
|
|
341
|
+
}
|
|
342
|
+
/** The same check-then-fetch dance downloadTranscribeModel does, for the embed table. */
|
|
343
|
+
export async function downloadEmbedModel(settings, modelName) {
|
|
344
|
+
const spec = embedModelSpec(modelName ?? settings.model);
|
|
345
|
+
const spin = spinner(`Checking ${spec.file}…`);
|
|
346
|
+
try {
|
|
347
|
+
const result = await downloadEmbed(settings.modelsDir, spec, (progress) => {
|
|
348
|
+
const percent = Math.floor((progress.received / progress.total) * 100);
|
|
349
|
+
spin.update(`Downloading ${spec.file} — ${mib(progress.received)} / ${mib(progress.total)} MiB (${percent}%)`);
|
|
350
|
+
});
|
|
351
|
+
spin.stop(ok(`${spec.file} (${mib(spec.bytes)} MiB) ${result.alreadyPresent ? "already present" : "verified"}`));
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
spin.stop();
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
329
358
|
/** What identifies each provider on screen. Keyed like PROVIDERS, never branched on. */
|
|
330
359
|
const PROVIDER_ROWS = {
|
|
331
360
|
local: (settings) => [
|
|
@@ -617,7 +646,10 @@ export async function linkAndSync(config, announce = () => { }, w = null) {
|
|
|
617
646
|
announce("Link your phone");
|
|
618
647
|
let account;
|
|
619
648
|
try {
|
|
620
|
-
account =
|
|
649
|
+
account =
|
|
650
|
+
phone === null
|
|
651
|
+
? await linkByQr(selected.paths, waiting, w)
|
|
652
|
+
: await linkByCode(selected.paths.authDir, phone, waiting, w);
|
|
621
653
|
}
|
|
622
654
|
catch (err) {
|
|
623
655
|
waiting.stop();
|
|
@@ -732,7 +764,8 @@ export async function yieldSession(config, lockFile, why = "pairing") {
|
|
|
732
764
|
return () => { };
|
|
733
765
|
const held = serviceHolding(config.dataDir, running);
|
|
734
766
|
if (held === null) {
|
|
735
|
-
throw leftoverRefusal(config) ??
|
|
767
|
+
throw (leftoverRefusal(config) ??
|
|
768
|
+
new WazapError("WHATSAPP_ERROR", `wazap is running (pid ${running}).`, leftoverFix(running)));
|
|
736
769
|
}
|
|
737
770
|
say(info(`Stopping the wazap service for ${why}`));
|
|
738
771
|
held.supervisor.stop(held.record);
|
package/dist/compact.js
CHANGED
|
@@ -30,7 +30,13 @@ export function compactConversations(conversations) {
|
|
|
30
30
|
last.message_ids.push(m.message_id);
|
|
31
31
|
continue;
|
|
32
32
|
}
|
|
33
|
-
lines.push({
|
|
33
|
+
lines.push({
|
|
34
|
+
timestamp: m.timestamp,
|
|
35
|
+
sender: m.sender.id,
|
|
36
|
+
from_me: m.from_me,
|
|
37
|
+
text: m.text,
|
|
38
|
+
message_ids: [m.message_id],
|
|
39
|
+
});
|
|
34
40
|
// The name rides on the line, once, for the renderer.
|
|
35
41
|
lines[lines.length - 1].name = m.from_me
|
|
36
42
|
? "me"
|
|
@@ -40,7 +46,14 @@ export function compactConversations(conversations) {
|
|
|
40
46
|
}
|
|
41
47
|
if (lines.length === 0 && dropped.media === 0 && dropped.wordless === 0)
|
|
42
48
|
continue;
|
|
43
|
-
out.push({
|
|
49
|
+
out.push({
|
|
50
|
+
chat_id: c.chat_id,
|
|
51
|
+
chat_name: c.chat_name,
|
|
52
|
+
type: c.type,
|
|
53
|
+
...(c.note ? { note: c.note } : {}),
|
|
54
|
+
lines,
|
|
55
|
+
dropped,
|
|
56
|
+
});
|
|
44
57
|
}
|
|
45
58
|
return out;
|
|
46
59
|
}
|
package/dist/config.js
CHANGED
|
@@ -50,6 +50,7 @@ const COMMAND_ARGS = {
|
|
|
50
50
|
// No positional means the first available provider; `off` takes the tunnel down.
|
|
51
51
|
expose: [0, 1],
|
|
52
52
|
transcribe: [1, 2],
|
|
53
|
+
embed: [1],
|
|
53
54
|
update: [0],
|
|
54
55
|
webhook: [1],
|
|
55
56
|
account: [1, 2],
|
|
@@ -67,8 +68,9 @@ const COMMAND_USAGE = {
|
|
|
67
68
|
skills: "Run `wazap skills install [<harness>]`",
|
|
68
69
|
service: "Run `wazap service install|status|start|stop|restart|logs|uninstall`",
|
|
69
70
|
transcribe: "Run `wazap transcribe download` or `wazap transcribe test <audio file>`",
|
|
71
|
+
embed: "Run `wazap embed download`",
|
|
70
72
|
contacts: "Run `wazap contacts resync`",
|
|
71
|
-
config: "Run `wazap config`, `wazap config writes on|off`, `wazap config transcribe local|openai|off`, or `wazap config webhook on|off`",
|
|
73
|
+
config: "Run `wazap config`, `wazap config writes on|off`, `wazap config transcribe local|openai|off`, `wazap config recall local|off`, or `wazap config webhook on|off`",
|
|
72
74
|
webhook: "Run `wazap webhook test`",
|
|
73
75
|
account: ACCOUNT_USAGE,
|
|
74
76
|
migrate: MIGRATE_USAGE,
|
|
@@ -225,6 +227,7 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
225
227
|
rateLimit: sourceOf("WAZAP_RATE_LIMIT", false),
|
|
226
228
|
transcribe: sourceOf("WAZAP_TRANSCRIBE", false),
|
|
227
229
|
webhook: sourceOf("WAZAP_WEBHOOK", false),
|
|
230
|
+
recall: sourceOf("WAZAP_RECALL", false),
|
|
228
231
|
},
|
|
229
232
|
command,
|
|
230
233
|
explicitCommand: first !== undefined,
|
package/dist/connect.js
CHANGED
|
@@ -135,8 +135,18 @@ export function whereInstalled(binPath = process.argv[1] ?? "", pathEnv = proces
|
|
|
135
135
|
const script = binPath === "" ? "" : resolve(binPath);
|
|
136
136
|
if (isNpxPath(binPath))
|
|
137
137
|
return { kind: "npx", script };
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
const onPath = commandPath("wazap", pathEnv, exists);
|
|
139
|
+
if (onPath) {
|
|
140
|
+
try {
|
|
141
|
+
if (realpathSync(onPath) === realpathSync(script))
|
|
142
|
+
return { kind: "global", script };
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// A package path or a direct launcher can still be classified when inspecting another host.
|
|
146
|
+
if (/[/\\]node_modules[/\\]wazap(?:-mcp)?[/\\]/.test(script) || resolve(onPath) === script)
|
|
147
|
+
return { kind: "global", script };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
140
150
|
return { kind: "checkout", script };
|
|
141
151
|
}
|
|
142
152
|
const GLOBAL_FIX = "run `npm i -g wazap-mcp` yourself (sudo on some Linux installs), then `wazap setup` again";
|
|
@@ -145,7 +155,12 @@ export function installGlobally(version = WAZAP_VERSION, npm = "npm") {
|
|
|
145
155
|
const result = spawnSync(npm, ["install", "-g", `wazap-mcp@${version}`], { stdio: "inherit" });
|
|
146
156
|
if (result.error !== undefined || result.status !== 0) {
|
|
147
157
|
const detail = result.error === undefined ? `exit ${result.status ?? -1}` : result.error.message;
|
|
148
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
name: "install",
|
|
160
|
+
state: "fail",
|
|
161
|
+
detail: `npm install -g wazap-mcp@${version} failed (${detail})`,
|
|
162
|
+
fix: GLOBAL_FIX,
|
|
163
|
+
};
|
|
149
164
|
}
|
|
150
165
|
return { name: "install", state: "ok", detail: `wazap-mcp@${version} installed globally` };
|
|
151
166
|
}
|
|
@@ -224,8 +239,9 @@ export const GUI_PATH = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin";
|
|
|
224
239
|
export function mcpEntry(config, spec, install = whereInstalled()) {
|
|
225
240
|
const entry = entryFor(install);
|
|
226
241
|
// The global `wazap` bin is a symlink into the package, and launchd's PATH has
|
|
227
|
-
// neither it nor npx,
|
|
228
|
-
|
|
242
|
+
// neither it, nor npx, nor the bare `node` a checkout entry would name, so a
|
|
243
|
+
// GUI client gets this Node and the script behind it.
|
|
244
|
+
if (spec.gui && (entry.command === "wazap" || install.kind === "checkout")) {
|
|
229
245
|
entry.command = process.execPath;
|
|
230
246
|
entry.args = [realpathSync(install.script)];
|
|
231
247
|
}
|
|
@@ -244,7 +260,11 @@ export function launchCheck(spec, entry, pathEnv = GUI_PATH, exists, platform =
|
|
|
244
260
|
if (platform !== "darwin")
|
|
245
261
|
return { name: "launch", state: "info", detail: "not checked on this platform" };
|
|
246
262
|
if (isAbsolute(entry.command) || commandOnPath(entry.command, pathEnv, exists)) {
|
|
247
|
-
return {
|
|
263
|
+
return {
|
|
264
|
+
name: "launch",
|
|
265
|
+
state: "ok",
|
|
266
|
+
detail: `${spec.describe} can start \`${entry.command}\` without your shell PATH`,
|
|
267
|
+
};
|
|
248
268
|
}
|
|
249
269
|
return {
|
|
250
270
|
name: "launch",
|
package/dist/deps.js
CHANGED
|
@@ -12,6 +12,7 @@ import { brand, info } from "./ui.js";
|
|
|
12
12
|
export const DEPS = {
|
|
13
13
|
whisper: { binary: "whisper-cli", brew: "whisper-cpp", why: "transcribes voice messages locally" },
|
|
14
14
|
ffmpeg: { binary: "ffmpeg", brew: "ffmpeg", why: "converts voice notes for whisper" },
|
|
15
|
+
llama: { binary: "llama-server", brew: "llama.cpp", why: "embeds messages for local semantic recall" },
|
|
15
16
|
tailscale: { binary: "tailscale", brew: "tailscale", why: "gives wazap a public https URL" },
|
|
16
17
|
cloudflared: { binary: "cloudflared", brew: "cloudflared", why: "gives wazap a public https URL" },
|
|
17
18
|
};
|
package/dist/doctor.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { accessSync, constants, statSync } from "node:fs";
|
|
2
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, accountPaths, 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";
|
|
8
|
+
import { EMBED_MODELS, embedModelPath, embedReady, readRecallSettings } from "./recall/index.js";
|
|
8
9
|
import { installedService } from "./service.js";
|
|
9
10
|
import { detectedTargets, skillState } from "./skills.js";
|
|
10
11
|
import { MODELS, findWhisper, localProvider, maskKey, modelPath, readTranscribeSettings, which, } from "./transcribe/index.js";
|
|
@@ -25,6 +26,7 @@ const CHECKS = [
|
|
|
25
26
|
checkSkills,
|
|
26
27
|
checkOAuth,
|
|
27
28
|
checkTranscribe,
|
|
29
|
+
checkRecall,
|
|
28
30
|
checkWebhook,
|
|
29
31
|
checkUpdate,
|
|
30
32
|
];
|
|
@@ -34,7 +36,7 @@ export async function runChecks(config) {
|
|
|
34
36
|
const checks = [];
|
|
35
37
|
for (const check of CHECKS)
|
|
36
38
|
checks.push(...[await check(config)].flat());
|
|
37
|
-
let linked
|
|
39
|
+
let linked;
|
|
38
40
|
try {
|
|
39
41
|
linked = anyAccountLinked(config.dataDir);
|
|
40
42
|
}
|
|
@@ -81,7 +83,12 @@ function checkDataDir(config) {
|
|
|
81
83
|
return { name: "data dir", state: "info", detail: `${dir} does not exist yet (login creates it)` };
|
|
82
84
|
}
|
|
83
85
|
if (!stat.isDirectory()) {
|
|
84
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
name: "data dir",
|
|
88
|
+
state: "fail",
|
|
89
|
+
detail: `${dir} is not a directory`,
|
|
90
|
+
fix: "move it aside or use --data-dir",
|
|
91
|
+
};
|
|
85
92
|
}
|
|
86
93
|
const mode = stat.mode & 0o777;
|
|
87
94
|
if (process.platform !== "win32" && mode !== 0o700) {
|
|
@@ -96,7 +103,12 @@ function checkDataDir(config) {
|
|
|
96
103
|
accessSync(dir, constants.W_OK);
|
|
97
104
|
}
|
|
98
105
|
catch {
|
|
99
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
name: "data dir",
|
|
108
|
+
state: "fail",
|
|
109
|
+
detail: `${dir} is not writable`,
|
|
110
|
+
fix: "fix its ownership or permissions",
|
|
111
|
+
};
|
|
100
112
|
}
|
|
101
113
|
return { name: "data dir", state: "ok", detail: `${dir} (0700, writable)` };
|
|
102
114
|
}
|
|
@@ -165,7 +177,11 @@ function checkCredentials(config) {
|
|
|
165
177
|
if (linkedIds.length === 0)
|
|
166
178
|
return { name: "credentials", state: "info", detail: "no account linked yet" };
|
|
167
179
|
// The number is deliberately absent: status is the thing people screenshot.
|
|
168
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
name: "credentials",
|
|
182
|
+
state: "ok",
|
|
183
|
+
detail: records.length > 1 ? `readable (${linkedIds.join(", ")})` : "readable",
|
|
184
|
+
};
|
|
169
185
|
}
|
|
170
186
|
function checkWrites(config) {
|
|
171
187
|
const selected = resolveAccount(config.dataDir, config.accountId);
|
|
@@ -278,6 +294,35 @@ async function localChecks(settings) {
|
|
|
278
294
|
: { name: "model", state: "ok", detail: `${spec.file} (${Math.round(size / MIB)} MiB)` },
|
|
279
295
|
];
|
|
280
296
|
}
|
|
297
|
+
const RECALL_OFF_FIX = "run `wazap config recall local` to search messages by meaning";
|
|
298
|
+
/**
|
|
299
|
+
* Off is quiet; on reports the sidecar binary and the model file, the two
|
|
300
|
+
* things `embed download` plus an install can repair.
|
|
301
|
+
*/
|
|
302
|
+
async function checkRecall(config) {
|
|
303
|
+
let settings;
|
|
304
|
+
try {
|
|
305
|
+
settings = readRecallSettings(process.env, config.dataDir);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const failure = asWazapError(err);
|
|
309
|
+
return [{ name: "recall", state: "fail", detail: failure.message, fix: failure.fix }];
|
|
310
|
+
}
|
|
311
|
+
if (!settings.enabled)
|
|
312
|
+
return [{ name: "recall", state: "info", detail: "off", fix: RECALL_OFF_FIX }];
|
|
313
|
+
const spec = EMBED_MODELS[settings.model];
|
|
314
|
+
const size = fileSize(embedModelPath(settings.modelsDir, spec));
|
|
315
|
+
const readiness = await embedReady(settings, spec);
|
|
316
|
+
return [
|
|
317
|
+
{ name: "recall", state: "ok", detail: `local (${settings.model})` },
|
|
318
|
+
readiness.ok
|
|
319
|
+
? { name: "llama-server", state: "ok", detail: settings.embedUrl ?? "found" }
|
|
320
|
+
: { name: "llama-server", state: "fail", detail: readiness.detail, fix: readiness.fix },
|
|
321
|
+
size === null && settings.embedUrl === null
|
|
322
|
+
? { name: "embed model", state: "fail", detail: `${spec.file} is not downloaded`, fix: "run `wazap embed download`" }
|
|
323
|
+
: { name: "embed model", state: "ok", detail: `${spec.file} (${Math.round((size ?? 0) / MIB)} MiB)` },
|
|
324
|
+
];
|
|
325
|
+
}
|
|
281
326
|
/** W1 webhook: off is quiet; on without a URL or secret is a visible fail. */
|
|
282
327
|
export function webhookCheck(env = process.env) {
|
|
283
328
|
const settings = readWebhookSettings(env);
|
|
@@ -341,6 +386,11 @@ async function checkUpdate() {
|
|
|
341
386
|
if (latest === null)
|
|
342
387
|
return { name: "update", state: "info", detail: "update check skipped (no answer)" };
|
|
343
388
|
return isNewer(latest, WAZAP_VERSION)
|
|
344
|
-
? {
|
|
389
|
+
? {
|
|
390
|
+
name: "update",
|
|
391
|
+
state: "info",
|
|
392
|
+
detail: `${latest} is out (running ${WAZAP_VERSION})`,
|
|
393
|
+
fix: "run `wazap update`",
|
|
394
|
+
}
|
|
345
395
|
: { name: "update", state: "ok", detail: `${WAZAP_VERSION} is current` };
|
|
346
396
|
}
|
package/dist/drafts.js
CHANGED
|
@@ -142,6 +142,9 @@ function formatNumber(digits) {
|
|
|
142
142
|
const raw = digits.startsWith("+") ? digits.slice(1) : digits;
|
|
143
143
|
if (!/^\d+$/.test(raw) || raw.length < 4)
|
|
144
144
|
return digits.startsWith("+") ? digits : `+${digits}`;
|
|
145
|
-
const rest = raw
|
|
145
|
+
const rest = raw
|
|
146
|
+
.slice(2)
|
|
147
|
+
.match(/.{1,3}/g)
|
|
148
|
+
?.join(" ") ?? raw.slice(2);
|
|
146
149
|
return `+${raw.slice(0, 2)} ${rest}`;
|
|
147
150
|
}
|
package/dist/errors.js
CHANGED
|
@@ -29,6 +29,7 @@ export const ERROR_GUIDE = {
|
|
|
29
29
|
NOT_ADMIN: "The linked account is not an admin of that group, so this action is refused. Do not retry.",
|
|
30
30
|
GROUP_ANNOUNCEMENT_ONLY: "Only admins may post in that group. Do not retry.",
|
|
31
31
|
MEDIA_UNAVAILABLE: "The media expired on WhatsApp's servers or was never synced. Do not retry; ask the sender to resend.",
|
|
32
|
+
MEDIA_ACCESS_DENIED: "The URL is not a public http(s) address, or resolves to a private or internal one. Pass a public URL or download the file and use file_path.",
|
|
32
33
|
FILE_NOT_FOUND: "The local path does not exist on the machine running wazap. Check the path with the user.",
|
|
33
34
|
FILE_TOO_LARGE: "The file is too large. Chat media may be 100 MB; a profile picture may be 10 MB. Send a smaller file.",
|
|
34
35
|
INVALID_IMAGE: "The file is not a JPEG, PNG or WebP. Pass a photo via file_path or url; GIF, video and documents are refused.",
|
|
@@ -41,6 +42,8 @@ export const ERROR_GUIDE = {
|
|
|
41
42
|
RATE_LIMITED: "Too many writes too fast. Wait the number of seconds in the fix, then retry once.",
|
|
42
43
|
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.",
|
|
43
44
|
TRANSCRIBE_FAILED: "The transcription provider ran and failed. Read the message; retry once at most.",
|
|
45
|
+
RECALL_UNAVAILABLE: "Semantic recall is off, or llama.cpp or the embedding model is missing. Tell the user to run the command in the fix; do not retry.",
|
|
46
|
+
RECALL_FAILED: "The embedding backend ran and failed. Read the message; retry once at most.",
|
|
44
47
|
TIMEOUT: "WhatsApp did not answer in time. Retry once; if it fails again, call get_status.",
|
|
45
48
|
SERVICE_ERROR: "wazap's own background service could not be managed. This is a machine problem, not a WhatsApp one: read the fix and tell the user.",
|
|
46
49
|
DRAFT_NOT_FOUND: "That draft_id is unknown or was already sent. Call the send tool again to draft, show the new preview, then confirm_send.",
|
package/dist/gif.js
CHANGED
|
@@ -25,11 +25,18 @@ export async function gifToMp4(gif) {
|
|
|
25
25
|
const output = join(dir, "out.mp4");
|
|
26
26
|
await writeFile(input, gif);
|
|
27
27
|
const args = [
|
|
28
|
-
"-nostdin",
|
|
29
|
-
"-
|
|
30
|
-
"
|
|
31
|
-
"-
|
|
32
|
-
"-
|
|
28
|
+
"-nostdin",
|
|
29
|
+
"-loglevel",
|
|
30
|
+
"error",
|
|
31
|
+
"-y",
|
|
32
|
+
"-i",
|
|
33
|
+
input,
|
|
34
|
+
"-movflags",
|
|
35
|
+
"faststart",
|
|
36
|
+
"-pix_fmt",
|
|
37
|
+
"yuv420p",
|
|
38
|
+
"-vf",
|
|
39
|
+
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
|
33
40
|
"-an",
|
|
34
41
|
output,
|
|
35
42
|
];
|
package/dist/ids.js
CHANGED
|
@@ -2,7 +2,10 @@ import { WazapError } from "./errors.js";
|
|
|
2
2
|
const PHONE_EXAMPLE = "Use international format, e.g. +15550100";
|
|
3
3
|
/** Digits of a phone number in international format, or INVALID_PHONE. */
|
|
4
4
|
export function normalizePhone(input) {
|
|
5
|
-
const digits = input
|
|
5
|
+
const digits = input
|
|
6
|
+
.trim()
|
|
7
|
+
.replace(/^\+/, "")
|
|
8
|
+
.replace(/[\s\-().]/g, "");
|
|
6
9
|
if (!/^\d+$/.test(digits) || digits.startsWith("0") || digits.length < 8 || digits.length > 15) {
|
|
7
10
|
throw new WazapError("INVALID_PHONE", `"${input.trim()}" is not a phone number in international format.`, PHONE_EXAMPLE);
|
|
8
11
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { BANNER } from "./banner.js";
|
|
3
|
-
import { runAccount, runContacts, runGreet, runLogin, runLogout, runMigrate, runServe, runStatus, runTranscribe } from "./cli.js";
|
|
3
|
+
import { runAccount, runContacts, runEmbed, runGreet, runLogin, runLogout, runMigrate, runServe, runStatus, runTranscribe, } from "./cli.js";
|
|
4
4
|
import { WAZAP_VERSION, parseCli, pickDefaultAction } from "./config.js";
|
|
5
5
|
import { migrateLayout } from "./migrate.js";
|
|
6
6
|
import { CLIENT_NAMES, runConnect } from "./connect.js";
|
|
@@ -24,11 +24,12 @@ Usage:
|
|
|
24
24
|
wazap service ${SERVICE_VERBS}
|
|
25
25
|
Keep the server running in the background, under launchd or systemd
|
|
26
26
|
wazap expose [tailscale|cloudflare|off] Give the running service a public https URL cloud agents can reach
|
|
27
|
-
wazap config [writes on|off] [transcribe local|openai|off] [webhook on|off]
|
|
27
|
+
wazap config [writes on|off] [transcribe local|openai|off] [recall local|off] [webhook on|off]
|
|
28
28
|
Show the effective settings, or change one
|
|
29
29
|
wazap webhook test [--event <name>] [--account <id>] POST a test event 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
|
+
wazap embed download [--model <alias>] Fetch the llama.cpp embedding model for semantic recall
|
|
32
33
|
wazap contacts resync Fetch the phone's address book from WhatsApp again
|
|
33
34
|
wazap update [--dry-run] Upgrade wazap, then the service and the skills that follow it
|
|
34
35
|
wazap status [--live] [--json] [--account <id>] Check the install, the session and the server
|
|
@@ -62,7 +63,8 @@ Options:
|
|
|
62
63
|
--transcribe <how> With setup: answer the transcription question (local, openai or off)
|
|
63
64
|
--service With setup: keep wazap running on this machine, without asking
|
|
64
65
|
--expose With setup: also give it a public URL cloud agents can reach
|
|
65
|
-
--model <alias> With transcribe download: turbo (default), large-v3 or medium
|
|
66
|
+
--model <alias> With transcribe download: turbo (default), large-v3 or medium.
|
|
67
|
+
With embed download: embeddinggemma-300m (default) or e5-base-multilingual
|
|
66
68
|
--dry-run With connect, skills install, service install or update: print what would happen, and do nothing
|
|
67
69
|
--live With status: reach WhatsApp for real, then close the connection
|
|
68
70
|
--json With status: print the whole report as one JSON object on stdout
|
|
@@ -78,7 +80,8 @@ WAZAP_OAUTH_PASSWORD, WAZAP_RATE_LIMIT,
|
|
|
78
80
|
WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK, WAZAP_TRANSCRIBE, WAZAP_TRANSCRIBE_AUTO,
|
|
79
81
|
WAZAP_TRANSCRIBE_LANGUAGE, WAZAP_TRANSCRIBE_API_KEY, WAZAP_TRANSCRIBE_URL, WAZAP_TRANSCRIBE_MODEL,
|
|
80
82
|
WAZAP_WHISPER_MODEL, WAZAP_WHISPER_BIN, WAZAP_WEBHOOK, WAZAP_WEBHOOK_URL,
|
|
81
|
-
WAZAP_WEBHOOK_SECRET, WAZAP_WEBHOOK_EVENTS
|
|
83
|
+
WAZAP_WEBHOOK_SECRET, WAZAP_WEBHOOK_EVENTS, WAZAP_RECALL, WAZAP_RECALL_MAX,
|
|
84
|
+
WAZAP_EMBED_MODEL, WAZAP_EMBED_BIN.
|
|
82
85
|
An optional <data-dir>/.env is loaded if present.`;
|
|
83
86
|
async function main() {
|
|
84
87
|
const invocation = parseCli();
|
|
@@ -95,7 +98,16 @@ async function main() {
|
|
|
95
98
|
// half-finished migrate and then fail to undo it. The other exempt commands
|
|
96
99
|
// never open account state, and `service stop` is how a lock that blocks the
|
|
97
100
|
// migration is released.
|
|
98
|
-
const MIGRATE_EXEMPT = new Set([
|
|
101
|
+
const MIGRATE_EXEMPT = new Set([
|
|
102
|
+
"migrate",
|
|
103
|
+
"service",
|
|
104
|
+
"connect",
|
|
105
|
+
"skills",
|
|
106
|
+
"expose",
|
|
107
|
+
"update",
|
|
108
|
+
"transcribe",
|
|
109
|
+
"embed",
|
|
110
|
+
]);
|
|
99
111
|
if (!MIGRATE_EXEMPT.has(config.command))
|
|
100
112
|
migrateLayout(config.dataDir);
|
|
101
113
|
switch (config.command) {
|
|
@@ -130,6 +142,9 @@ async function main() {
|
|
|
130
142
|
case "transcribe":
|
|
131
143
|
await runTranscribe(config);
|
|
132
144
|
return;
|
|
145
|
+
case "embed":
|
|
146
|
+
await runEmbed(config);
|
|
147
|
+
return;
|
|
133
148
|
case "contacts":
|
|
134
149
|
await runContacts(config);
|
|
135
150
|
return;
|
package/dist/messages.js
CHANGED
|
@@ -82,7 +82,11 @@ const RULES = {
|
|
|
82
82
|
extendedTextMessage: { type: "text", tag: "[text]", text: (m) => m.extendedTextMessage?.text },
|
|
83
83
|
imageMessage: { type: "image", tag: "[image]", caption: (m) => m.imageMessage?.caption },
|
|
84
84
|
// A GIF on WhatsApp is an mp4 with a flag; the reader deserves the word.
|
|
85
|
-
videoMessage: {
|
|
85
|
+
videoMessage: {
|
|
86
|
+
type: "video",
|
|
87
|
+
tag: (m) => (m.videoMessage?.gifPlayback ? "[gif]" : "[video]"),
|
|
88
|
+
caption: (m) => m.videoMessage?.caption,
|
|
89
|
+
},
|
|
86
90
|
ptvMessage: { type: "video", tag: "[video]", caption: (m) => m.ptvMessage?.caption },
|
|
87
91
|
audioMessage: {
|
|
88
92
|
type: (m) => (m.audioMessage?.ptt ? "voice" : "audio"),
|
|
@@ -152,7 +156,12 @@ const MEDIA_KEYS = [
|
|
|
152
156
|
"stickerMessage",
|
|
153
157
|
];
|
|
154
158
|
/** Media whose WhatsApp envelope carries a JPEG preview of a few KB. */
|
|
155
|
-
const THUMBNAIL_KEYS = [
|
|
159
|
+
const THUMBNAIL_KEYS = [
|
|
160
|
+
"imageMessage",
|
|
161
|
+
"videoMessage",
|
|
162
|
+
"ptvMessage",
|
|
163
|
+
"documentMessage",
|
|
164
|
+
];
|
|
156
165
|
/** Envelopes that only wrap another message; the inner one is the real content. */
|
|
157
166
|
function unwrapEnvelopes(content) {
|
|
158
167
|
let current = content ?? undefined;
|
|
@@ -376,6 +385,35 @@ function spokenTranscript(raw, transcript) {
|
|
|
376
385
|
const type = messageType(raw);
|
|
377
386
|
return type === "voice" || type === "audio" ? transcript.text : undefined;
|
|
378
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* What the recall index stores for a message: the rendered view text when the
|
|
390
|
+
* message carries words a person chose, and null when it is only a placeholder
|
|
391
|
+
* — "[sticker]", "[deleted]", a call log, a reaction. A bare tag is nothing to
|
|
392
|
+
* search by; a voice note becomes indexable once its transcript exists.
|
|
393
|
+
*/
|
|
394
|
+
export function searchableText(raw, transcript) {
|
|
395
|
+
const spoken = spokenTranscript(raw, transcript);
|
|
396
|
+
const content = unwrapEnvelopes(raw.message);
|
|
397
|
+
const { rule, content: node } = ruleFor(content);
|
|
398
|
+
const type = resolve(rule.type, node);
|
|
399
|
+
if (type === "reaction" || type === "deleted" || type === "system")
|
|
400
|
+
return null;
|
|
401
|
+
const own = rule.text?.(node)?.trim() || rule.caption?.(node)?.trim() || rule.detail?.(node)?.trim() || "";
|
|
402
|
+
// No letter or digit means no words to embed — a "🥰🥰" or "..." only adds
|
|
403
|
+
// noise that outranks real hits on short queries.
|
|
404
|
+
if (!/[\p{L}\p{N}]/u.test(own) && spoken === undefined)
|
|
405
|
+
return null;
|
|
406
|
+
return viewText(raw, transcript);
|
|
407
|
+
}
|
|
408
|
+
/** The message a REVOKE protocol message takes back, when there is one. */
|
|
409
|
+
export function revokedTargetKey(raw) {
|
|
410
|
+
const content = unwrapEnvelopes(raw.message);
|
|
411
|
+
const proto_ = content?.protocolMessage;
|
|
412
|
+
if (proto_?.type !== proto.Message.ProtocolMessage.Type.REVOKE)
|
|
413
|
+
return undefined;
|
|
414
|
+
const key = proto_.key;
|
|
415
|
+
return key?.id ? key : undefined;
|
|
416
|
+
}
|
|
379
417
|
/**
|
|
380
418
|
* What a reader sees. searchMessages matches on this rather than on the bare
|
|
381
419
|
* placeholder, so a transcript is findable by the words it puts on the screen.
|
|
@@ -482,7 +520,7 @@ function senderJid(raw, ctx) {
|
|
|
482
520
|
const from = raw.key.participant || raw.participant || raw.key.remoteJid || "";
|
|
483
521
|
return from ? ctx.canonical(from) : ctx.ownId;
|
|
484
522
|
}
|
|
485
|
-
function phoneOf(jid) {
|
|
523
|
+
export function phoneOf(jid) {
|
|
486
524
|
const [user = "", domain] = jid.split("@");
|
|
487
525
|
return domain === "s.whatsapp.net" && /^\d+$/.test(user) ? user : undefined;
|
|
488
526
|
}
|
package/dist/notes.js
CHANGED
|
@@ -8,6 +8,9 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
export class Notes {
|
|
10
10
|
file;
|
|
11
|
+
/** Why the file on disk could not be read, or why the last save failed. Null when healthy. */
|
|
12
|
+
error = null;
|
|
13
|
+
loadError = null;
|
|
11
14
|
contacts = new Map();
|
|
12
15
|
handled = new Map();
|
|
13
16
|
constructor(file) {
|
|
@@ -19,24 +22,38 @@ export class Notes {
|
|
|
19
22
|
try {
|
|
20
23
|
parsed = JSON.parse(readFileSync(this.file, "utf8"));
|
|
21
24
|
}
|
|
22
|
-
catch {
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== "ENOENT")
|
|
27
|
+
this.error = this.loadError = error instanceof Error ? error.message : String(error);
|
|
23
28
|
return;
|
|
24
29
|
}
|
|
25
|
-
if (parsed?.v !== 1)
|
|
30
|
+
if (parsed?.v !== 1) {
|
|
31
|
+
this.error = this.loadError = "Unsupported notes file version";
|
|
26
32
|
return;
|
|
33
|
+
}
|
|
27
34
|
for (const [jid, note] of Object.entries(parsed.contacts ?? {}))
|
|
28
35
|
this.contacts.set(jid, note);
|
|
29
36
|
for (const [jid, mark] of Object.entries(parsed.handled ?? {}))
|
|
30
37
|
this.handled.set(jid, mark);
|
|
31
38
|
}
|
|
32
39
|
save() {
|
|
40
|
+
// A file that never read cleanly must not be overwritten with a fresh one.
|
|
41
|
+
if (this.loadError)
|
|
42
|
+
throw new Error(`Cannot overwrite unreadable notes: ${this.loadError}`);
|
|
33
43
|
const data = {
|
|
34
44
|
v: 1,
|
|
35
45
|
contacts: Object.fromEntries(this.contacts),
|
|
36
46
|
handled: Object.fromEntries(this.handled),
|
|
37
47
|
};
|
|
38
48
|
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
39
|
-
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(this.file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
51
|
+
this.error = null;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
40
57
|
}
|
|
41
58
|
noteFor(jid) {
|
|
42
59
|
return this.contacts.get(jid)?.note;
|