wazap-mcp 0.16.0 → 0.17.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 +93 -11
- package/dist/accounts.js +22 -4
- package/dist/config.js +2 -0
- package/dist/index.js +4 -2
- package/dist/messages.js +3 -5
- package/dist/sent-ids.js +40 -0
- package/dist/settings.js +18 -8
- package/dist/transcribe/queue.js +21 -3
- package/dist/webhook.js +173 -23
- package/dist/whatsapp.js +87 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -545,9 +545,9 @@ response still carries `account_id`. `link_account` needs an account that
|
|
|
545
545
|
already exists. Five accounts is advice, not a cap. One phone number is one
|
|
546
546
|
account.
|
|
547
547
|
|
|
548
|
-
An account can override the global webhook URL and
|
|
549
|
-
(`webhook_url`, `webhook_secret`).
|
|
550
|
-
with that account's id and name.
|
|
548
|
+
An account can override the global webhook URL, secret and event list in
|
|
549
|
+
`accounts.json` (`webhook_url`, `webhook_secret`, `webhook_events`).
|
|
550
|
+
`wazap webhook test --account work` posts with that account's id and name.
|
|
551
551
|
|
|
552
552
|
## Several clients at once
|
|
553
553
|
|
|
@@ -704,38 +704,119 @@ What to know before exposing it:
|
|
|
704
704
|
|
|
705
705
|
## Outbound webhook
|
|
706
706
|
|
|
707
|
-
|
|
708
|
-
is
|
|
707
|
+
Live events POST to one URL. Off by default. History sync is not posted.
|
|
708
|
+
Only `message_received` is posted unless you ask for more, because a consumer
|
|
709
|
+
that answers every POST without reading `event` would otherwise answer the
|
|
710
|
+
messages its own owner typed on the phone.
|
|
711
|
+
|
|
712
|
+
The `event` field names one of three. `message_received` is a message another
|
|
713
|
+
person sent. `message_sent` is a message this account sent itself, typed on
|
|
714
|
+
the phone or on another linked device; a message wazap sent through its own
|
|
715
|
+
tools is not announced, so a consumer can never be made to answer itself.
|
|
716
|
+
`connection` says the link came up, went down or expired.
|
|
717
|
+
|
|
718
|
+
Ask for the other two in `WAZAP_WEBHOOK_EVENTS`, comma-separated
|
|
719
|
+
(`message_received,connection`), or say `all` for the three of them. Case
|
|
720
|
+
does not matter and the spaces around a name are ignored. An unknown name
|
|
721
|
+
fails `wazap status`, doctor and setup. An account carries its own list as
|
|
722
|
+
`webhook_events` in `accounts.json`.
|
|
709
723
|
|
|
710
724
|
```bash
|
|
711
725
|
npx wazap-mcp config webhook on # asks for URL + secret (secret is not echoed)
|
|
712
726
|
npx wazap-mcp webhook test # POST a probe event
|
|
727
|
+
npx wazap-mcp webhook test --event connection # needs connection enabled
|
|
713
728
|
npx wazap-mcp webhook test --account work
|
|
714
729
|
npx wazap-mcp config webhook off
|
|
715
730
|
```
|
|
716
731
|
|
|
717
732
|
On without a URL or secret fails `wazap status`, doctor and setup. A failed
|
|
718
733
|
delivery retries twice (200 ms, then 500 ms), then sets `webhook.last_error`
|
|
719
|
-
and leaves WhatsApp and MCP running. An account may set `webhook_url
|
|
720
|
-
`webhook_secret` in `accounts.json`; those win over the
|
|
734
|
+
and leaves WhatsApp and MCP running. An account may set `webhook_url`,
|
|
735
|
+
`webhook_secret` and `webhook_events` in `accounts.json`; those win over the
|
|
736
|
+
global URL, secret and event list, and `config webhook off --account work`
|
|
737
|
+
clears all three.
|
|
738
|
+
|
|
739
|
+
`webhook test --event <name>` posts nothing and exits non-zero when that
|
|
740
|
+
event is not enabled, and says what to enable it with. While the webhook is
|
|
741
|
+
on, `wazap config` prints the events it posts on an `events:` line.
|
|
721
742
|
|
|
722
743
|
HMAC: `X-Wazap-Signature` is `sha256=<hex>`, HMAC-SHA256 of the exact raw
|
|
723
744
|
JSON body with the secret that signed it. Verify that raw body, not a
|
|
724
745
|
re-serialized object. HTTPS only, except `http://` on loopback.
|
|
725
746
|
|
|
747
|
+
`text` is a preview, cut at 2000 characters and ending in a single `…`.
|
|
748
|
+
`truncated` is true when it was cut. For `kind: "audio"`, `text` is the
|
|
749
|
+
transcription when wazap auto-transcribed the note itself, which it does for
|
|
750
|
+
incoming notes only; the event waits up to 60 seconds for those words. In
|
|
751
|
+
every other case `text` is the `[voice message · 0:42]` placeholder: a note
|
|
752
|
+
you recorded yourself, a note longer than 600 seconds, a note WhatsApp stated
|
|
753
|
+
no duration for, and a transcription that failed. `ts` is the original local
|
|
754
|
+
time with a numeric offset, kept for consumers already reading it, and
|
|
755
|
+
`timestamp` is the same instant in UTC.
|
|
756
|
+
|
|
757
|
+
Events are not guaranteed to arrive in the order they happened. A message held
|
|
758
|
+
for its transcript is overtaken by the messages behind it, so order by
|
|
759
|
+
`timestamp` and not by arrival. Connection events are the exception: they are
|
|
760
|
+
delivered in the order the link moved in.
|
|
761
|
+
|
|
762
|
+
A message another person sent:
|
|
763
|
+
|
|
726
764
|
```json
|
|
727
765
|
{
|
|
728
766
|
"event": "message_received",
|
|
729
|
-
"from": "
|
|
767
|
+
"from": "15550100",
|
|
730
768
|
"chat_id": "15550100@s.whatsapp.net",
|
|
731
|
-
"ts": "2026-09-
|
|
732
|
-
"
|
|
769
|
+
"ts": "2026-09-08T17:00:00+03:00",
|
|
770
|
+
"timestamp": "2026-09-08T14:00:00.000Z",
|
|
771
|
+
"text": "hello, or a preview of something longer",
|
|
772
|
+
"truncated": false,
|
|
773
|
+
"kind": "text",
|
|
774
|
+
"from_me": false,
|
|
775
|
+
"is_self_chat": false,
|
|
733
776
|
"message_id": "false_15550100@s.whatsapp.net_3EB0…",
|
|
734
777
|
"account_id": "default",
|
|
735
778
|
"account_name": "default"
|
|
736
779
|
}
|
|
737
780
|
```
|
|
738
781
|
|
|
782
|
+
A message sent from the phone, here in the "Message yourself" chat:
|
|
783
|
+
|
|
784
|
+
```json
|
|
785
|
+
{
|
|
786
|
+
"event": "message_sent",
|
|
787
|
+
"from": "15551234",
|
|
788
|
+
"chat_id": "15551234@s.whatsapp.net",
|
|
789
|
+
"ts": "2026-09-08T17:04:12+03:00",
|
|
790
|
+
"timestamp": "2026-09-08T14:04:12.000Z",
|
|
791
|
+
"text": "call the notary at 14:00 on Wednesday",
|
|
792
|
+
"truncated": false,
|
|
793
|
+
"kind": "text",
|
|
794
|
+
"from_me": true,
|
|
795
|
+
"is_self_chat": true,
|
|
796
|
+
"message_id": "true_15551234@s.whatsapp.net_3EB0…",
|
|
797
|
+
"account_id": "default",
|
|
798
|
+
"account_name": "default"
|
|
799
|
+
}
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
A connection change. `status` is `linked`, `disconnected` or `expired`;
|
|
803
|
+
`not_linked`, `linking` and `connecting` post nothing, and two changes that
|
|
804
|
+
mean the same status post once.
|
|
805
|
+
|
|
806
|
+
```json
|
|
807
|
+
{
|
|
808
|
+
"event": "connection",
|
|
809
|
+
"status": "expired",
|
|
810
|
+
"timestamp": "2026-09-08T14:10:00.000Z",
|
|
811
|
+
"account_id": "default",
|
|
812
|
+
"account_name": "default"
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
`connection` reports what the socket does while wazap is running. A clean
|
|
817
|
+
shutdown posts nothing, and a crash posts nothing either, so silence does not
|
|
818
|
+
mean the link is up. Poll `get_status` when you need to know that.
|
|
819
|
+
|
|
739
820
|
## Settings
|
|
740
821
|
|
|
741
822
|
| Variable | Default | Meaning |
|
|
@@ -759,9 +840,10 @@ re-serialized object. HTTPS only, except `http://` on loopback.
|
|
|
759
840
|
| `WAZAP_TRANSCRIBE_API_KEY` | unset | API key; `OPENAI_API_KEY` is the fallback. Never a flag. |
|
|
760
841
|
| `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
|
|
761
842
|
| `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |
|
|
762
|
-
| `WAZAP_WEBHOOK` | `off` | `on` posts
|
|
843
|
+
| `WAZAP_WEBHOOK` | `off` | `on` posts the enabled events to the webhook URL. |
|
|
763
844
|
| `WAZAP_WEBHOOK_URL` | unset | HTTPS endpoint. `http://` only on loopback. An account `webhook_url` wins. |
|
|
764
845
|
| `WAZAP_WEBHOOK_SECRET` | unset | Shared secret for `X-Wazap-Signature`. Never a flag. An account `webhook_secret` wins. |
|
|
846
|
+
| `WAZAP_WEBHOOK_EVENTS` | unset (`message_received`) | Which events to post, comma-separated, or `all`. An account `webhook_events` wins. |
|
|
765
847
|
|
|
766
848
|
Flags beat environment variables, which beat `<data-dir>/.env`.
|
|
767
849
|
|
package/dist/accounts.js
CHANGED
|
@@ -2,7 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { readLinkedAccount } from "./auth-state.js";
|
|
4
4
|
import { accountPaths, paths } from "./config.js";
|
|
5
|
-
import { WazapError } from "./errors.js";
|
|
5
|
+
import { WazapError, asWazapError } from "./errors.js";
|
|
6
|
+
import { parseWebhookEvents } from "./webhook.js";
|
|
6
7
|
export const DEFAULT_ACCOUNT_ID = "default";
|
|
7
8
|
export const ACCOUNT_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
8
9
|
const FIX_LIST = "Run `wazap account list`";
|
|
@@ -72,10 +73,13 @@ function parseAccountRecord(value, file) {
|
|
|
72
73
|
}
|
|
73
74
|
record.rate_limit = value.rate_limit;
|
|
74
75
|
}
|
|
75
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
...record,
|
|
78
|
+
...webhookFields(value.id, value.webhook_url, value.webhook_secret, value.webhook_events, ` in ${file}`),
|
|
79
|
+
};
|
|
76
80
|
}
|
|
77
81
|
/** Shared by load and `setWebhook` so a writer cannot persist what load refuses. */
|
|
78
|
-
function webhookFields(id, url, secret, where = "", fix = "Fix or remove accounts.json") {
|
|
82
|
+
function webhookFields(id, url, secret, events, where = "", fix = "Fix or remove accounts.json") {
|
|
79
83
|
const fields = {};
|
|
80
84
|
if (url !== undefined) {
|
|
81
85
|
if (typeof url !== "string" || url.trim() === "") {
|
|
@@ -89,6 +93,19 @@ function webhookFields(id, url, secret, where = "", fix = "Fix or remove account
|
|
|
89
93
|
}
|
|
90
94
|
fields.webhook_secret = secret;
|
|
91
95
|
}
|
|
96
|
+
if (events !== undefined) {
|
|
97
|
+
if (typeof events !== "string" || events.trim() === "") {
|
|
98
|
+
throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_events.`, fix);
|
|
99
|
+
}
|
|
100
|
+
const trimmed = events.trim();
|
|
101
|
+
try {
|
|
102
|
+
parseWebhookEvents(trimmed);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_events: ${asWazapError(err).message}`, fix);
|
|
106
|
+
}
|
|
107
|
+
fields.webhook_events = trimmed;
|
|
108
|
+
}
|
|
92
109
|
return fields;
|
|
93
110
|
}
|
|
94
111
|
function parseAccountsFile(value, file) {
|
|
@@ -200,7 +217,7 @@ export class AccountRegistry {
|
|
|
200
217
|
setWebhook(id, webhook) {
|
|
201
218
|
this.commit(this.withAccount(id, (account) => ({
|
|
202
219
|
...account,
|
|
203
|
-
...webhookFields(id, webhook.url, webhook.secret, "", "Set a non-empty webhook URL or secret"),
|
|
220
|
+
...webhookFields(id, webhook.url, webhook.secret, undefined, "", "Set a non-empty webhook URL or secret"),
|
|
204
221
|
})));
|
|
205
222
|
}
|
|
206
223
|
/** Drop the per-account override so the account follows the global webhook again. */
|
|
@@ -209,6 +226,7 @@ export class AccountRegistry {
|
|
|
209
226
|
const next = { ...account };
|
|
210
227
|
delete next.webhook_url;
|
|
211
228
|
delete next.webhook_secret;
|
|
229
|
+
delete next.webhook_events;
|
|
212
230
|
return next;
|
|
213
231
|
}));
|
|
214
232
|
}
|
package/dist/config.js
CHANGED
|
@@ -163,6 +163,7 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
163
163
|
service: { type: "boolean" },
|
|
164
164
|
expose: { type: "boolean" },
|
|
165
165
|
yes: { type: "boolean", short: "y" },
|
|
166
|
+
event: { type: "string" },
|
|
166
167
|
account: { type: "string" },
|
|
167
168
|
name: { type: "string" },
|
|
168
169
|
help: { type: "boolean", short: "h" },
|
|
@@ -245,6 +246,7 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
245
246
|
keepRunning: values.expose === true ? "expose" : values.service === true ? "service" : null,
|
|
246
247
|
accountId: values.account,
|
|
247
248
|
accountName: values.name,
|
|
249
|
+
webhookEvent: values.event,
|
|
248
250
|
},
|
|
249
251
|
};
|
|
250
252
|
}
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ Usage:
|
|
|
26
26
|
wazap expose [tailscale|cloudflare|off] Give the running service a public https URL cloud agents can reach
|
|
27
27
|
wazap config [writes on|off] [transcribe local|openai|off] [webhook on|off]
|
|
28
28
|
Show the effective settings, or change one
|
|
29
|
-
wazap webhook test [--account <id>]
|
|
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
32
|
wazap contacts resync Fetch the phone's address book from WhatsApp again
|
|
@@ -46,6 +46,7 @@ Options:
|
|
|
46
46
|
--data-dir <path> Where wazap keeps its data (default ~/.wazap, or $WAZAP_DATA_DIR)
|
|
47
47
|
--account <id> With login, logout, status, contacts, config writes|webhook, webhook test:
|
|
48
48
|
pick this account. account add|enable|disable|default apply on server restart
|
|
49
|
+
--event <name> With webhook test: message_received (default), message_sent or connection
|
|
49
50
|
--name <name> With account add: a display name
|
|
50
51
|
--read-only Refuse every write; the write tools are not registered at all
|
|
51
52
|
--http Serve Streamable HTTP instead of stdio
|
|
@@ -76,7 +77,8 @@ WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WA
|
|
|
76
77
|
WAZAP_OAUTH_PASSWORD, WAZAP_RATE_LIMIT,
|
|
77
78
|
WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK, WAZAP_TRANSCRIBE, WAZAP_TRANSCRIBE_AUTO,
|
|
78
79
|
WAZAP_TRANSCRIBE_LANGUAGE, WAZAP_TRANSCRIBE_API_KEY, WAZAP_TRANSCRIBE_URL, WAZAP_TRANSCRIBE_MODEL,
|
|
79
|
-
WAZAP_WHISPER_MODEL, WAZAP_WHISPER_BIN, WAZAP_WEBHOOK, WAZAP_WEBHOOK_URL,
|
|
80
|
+
WAZAP_WHISPER_MODEL, WAZAP_WHISPER_BIN, WAZAP_WEBHOOK, WAZAP_WEBHOOK_URL,
|
|
81
|
+
WAZAP_WEBHOOK_SECRET, WAZAP_WEBHOOK_EVENTS.
|
|
80
82
|
An optional <data-dir>/.env is loaded if present.`;
|
|
81
83
|
async function main() {
|
|
82
84
|
const invocation = parseCli();
|
package/dist/messages.js
CHANGED
|
@@ -323,12 +323,10 @@ export function isStubEvent(raw) {
|
|
|
323
323
|
return stubKind(raw) !== undefined;
|
|
324
324
|
}
|
|
325
325
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
326
|
+
* Something a person sent or received, as opposed to a control notice, a stub
|
|
327
|
+
* or a system line. The webhook posts these in both directions.
|
|
328
328
|
*/
|
|
329
|
-
export function
|
|
330
|
-
if (raw.key.fromMe)
|
|
331
|
-
return false;
|
|
329
|
+
export function isUserMessage(raw) {
|
|
332
330
|
if (isControlMessage(raw) || isStubEvent(raw))
|
|
333
331
|
return false;
|
|
334
332
|
return messageType(raw) !== "system";
|
package/dist/sent-ids.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baileys message ids this process sent, kept only long enough for WhatsApp to
|
|
3
|
+
* echo them back. This is a backstop, not the mechanism: Baileys re-emits a local
|
|
4
|
+
* send as an `append`, and the webhook path runs on `notify` only, so that gate is
|
|
5
|
+
* what keeps wazap from announcing its own sends. Do not delete it believing this
|
|
6
|
+
* replaces it. What lands here is an echo that arrives as `notify` anyway, such as
|
|
7
|
+
* a server-side redelivery or a future send path. Identity is the bare `key.id`,
|
|
8
|
+
* unique per sender and stable while a chat jid is re-canonicalised, never the
|
|
9
|
+
* composite message id and never the text.
|
|
10
|
+
*/
|
|
11
|
+
export const SENT_TTL_MS = 10 * 60_000;
|
|
12
|
+
export class SentIds {
|
|
13
|
+
noted = new Map();
|
|
14
|
+
ttlMs;
|
|
15
|
+
clock;
|
|
16
|
+
constructor(opts = {}) {
|
|
17
|
+
this.ttlMs = opts.ttlMs ?? SENT_TTL_MS;
|
|
18
|
+
this.clock = opts.now ?? Date.now;
|
|
19
|
+
}
|
|
20
|
+
note(id) {
|
|
21
|
+
this.prune();
|
|
22
|
+
this.noted.set(id, this.clock());
|
|
23
|
+
}
|
|
24
|
+
has(id) {
|
|
25
|
+
this.prune();
|
|
26
|
+
return this.noted.has(id);
|
|
27
|
+
}
|
|
28
|
+
get size() {
|
|
29
|
+
this.prune();
|
|
30
|
+
return this.noted.size;
|
|
31
|
+
}
|
|
32
|
+
/** No timer: a long-lived service prunes on the reads and writes it already does. */
|
|
33
|
+
prune() {
|
|
34
|
+
const cutoff = this.clock() - this.ttlMs;
|
|
35
|
+
for (const [id, at] of this.noted) {
|
|
36
|
+
if (at <= cutoff)
|
|
37
|
+
this.noted.delete(id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/settings.js
CHANGED
|
@@ -8,7 +8,7 @@ import { lockHolder } from "./lock.js";
|
|
|
8
8
|
import { say } from "./logger.js";
|
|
9
9
|
import { maskKey, readTranscribeSettings, requireSafeUrl, stripPasted, transcribeReady, } from "./transcribe/index.js";
|
|
10
10
|
import { brand, dim, fix, ok, shortPath, warn } from "./ui.js";
|
|
11
|
-
import { WEBHOOK_ON_FIX, WEBHOOK_TEST_FIX, WebhookSink, readWebhookSettings, requireWebhookUrl, } from "./webhook.js";
|
|
11
|
+
import { WEBHOOK_ON_FIX, WEBHOOK_TEST_FIX, WebhookSink, parseWebhookEvent, readWebhookSettings, requireWebhookUrl, } from "./webhook.js";
|
|
12
12
|
/**
|
|
13
13
|
* dotenv 16 treats an unquoted `#` as a comment, even with no space, and
|
|
14
14
|
* trims unquoted padding. Quote so a secret like `p@ss#word` round-trips.
|
|
@@ -154,8 +154,13 @@ function transcribeRows(config) {
|
|
|
154
154
|
}
|
|
155
155
|
function accountWebhook(config) {
|
|
156
156
|
const selected = resolveAccount(config.dataDir, config.accountId);
|
|
157
|
-
const override = {
|
|
158
|
-
|
|
157
|
+
const override = {
|
|
158
|
+
url: selected.account.webhook_url,
|
|
159
|
+
secret: selected.account.webhook_secret,
|
|
160
|
+
events: selected.account.webhook_events,
|
|
161
|
+
};
|
|
162
|
+
const fromAccount = override.url !== undefined || override.secret !== undefined || override.events !== undefined;
|
|
163
|
+
const source = fromAccount ? "accounts.json" : config.sources.webhook;
|
|
159
164
|
return { override, source };
|
|
160
165
|
}
|
|
161
166
|
function webhookRows(config) {
|
|
@@ -165,7 +170,11 @@ function webhookRows(config) {
|
|
|
165
170
|
case "off":
|
|
166
171
|
return [`webhook: off (${source})`];
|
|
167
172
|
case "ready":
|
|
168
|
-
return [
|
|
173
|
+
return [
|
|
174
|
+
`webhook: on (${new URL(settings.url).host}) (${source})`,
|
|
175
|
+
`secret: ${maskKey(settings.secret)}`,
|
|
176
|
+
`events: ${settings.events.join(", ")}`,
|
|
177
|
+
];
|
|
169
178
|
case "invalid":
|
|
170
179
|
return [`webhook: ${settings.detail}${settings.fix === "" ? "" : ` — ${settings.fix}`}`];
|
|
171
180
|
default: {
|
|
@@ -191,7 +200,7 @@ async function applyWebhook(config, value) {
|
|
|
191
200
|
return;
|
|
192
201
|
}
|
|
193
202
|
setWebhookFlag(config, "off");
|
|
194
|
-
say(ok("webhook: off —
|
|
203
|
+
say(ok("webhook: off — nothing is posted anywhere. Turn it on with `wazap config webhook on`."));
|
|
195
204
|
say(dim(`Stored in ${shortPath(paths(config.dataDir).envFile)}.`));
|
|
196
205
|
warnIfServerRunning(config);
|
|
197
206
|
return;
|
|
@@ -235,7 +244,7 @@ async function enableWebhook(config) {
|
|
|
235
244
|
// The switch stays global; the URL and secret are this account's override.
|
|
236
245
|
AccountRegistry.load(config.dataDir).setWebhook(config.accountId, { url, secret });
|
|
237
246
|
setEnvSetting(p.envFile, "WAZAP_WEBHOOK", "on");
|
|
238
|
-
say(ok(`webhook: on for ${config.accountId} —
|
|
247
|
+
say(ok(`webhook: on for ${config.accountId} — messages both ways and link changes POST to ${new URL(url).host}.`));
|
|
239
248
|
say(dim(`URL and secret stored in ${shortPath(p.accountsFile)}; WAZAP_WEBHOOK=on in ${shortPath(p.envFile)}.`));
|
|
240
249
|
warnIfServerRunning(config);
|
|
241
250
|
return;
|
|
@@ -243,7 +252,7 @@ async function enableWebhook(config) {
|
|
|
243
252
|
setEnvSetting(p.envFile, "WAZAP_WEBHOOK", "on");
|
|
244
253
|
setEnvSetting(p.envFile, "WAZAP_WEBHOOK_URL", url);
|
|
245
254
|
setEnvSetting(p.envFile, "WAZAP_WEBHOOK_SECRET", secret);
|
|
246
|
-
say(ok(`webhook: on —
|
|
255
|
+
say(ok(`webhook: on — messages both ways and link changes POST to ${new URL(url).host}.`));
|
|
247
256
|
say(dim(`Stored in ${shortPath(p.envFile)}.`));
|
|
248
257
|
warnIfServerRunning(config);
|
|
249
258
|
}
|
|
@@ -251,8 +260,9 @@ function setWebhookFlag(config, value) {
|
|
|
251
260
|
setEnvSetting(paths(config.dataDir).envFile, "WAZAP_WEBHOOK", value);
|
|
252
261
|
}
|
|
253
262
|
async function testWebhook(config) {
|
|
263
|
+
const event = parseWebhookEvent(config.webhookEvent);
|
|
254
264
|
const selected = resolveAccount(config.dataDir, config.accountId);
|
|
255
|
-
const result = await new WebhookSink(process.env, { account: selected.account }).sendTest();
|
|
265
|
+
const result = await new WebhookSink(process.env, { account: selected.account }).sendTest(event);
|
|
256
266
|
if (result.ok) {
|
|
257
267
|
say(ok("webhook: test delivered"));
|
|
258
268
|
return;
|
package/dist/transcribe/queue.js
CHANGED
|
@@ -9,16 +9,31 @@ export class TranscribeQueue {
|
|
|
9
9
|
pending = [];
|
|
10
10
|
inFlight = null;
|
|
11
11
|
waiters = [];
|
|
12
|
+
/** One entry per id queued or in flight, which is also how a repeat enqueue is spotted. */
|
|
13
|
+
settling = new Map();
|
|
12
14
|
constructor(run) {
|
|
13
15
|
this.run = run;
|
|
14
16
|
}
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Settles when this id's own run settles, so a caller can wait for one
|
|
19
|
+
* transcript without waiting for the queue behind it. It never rejects: a run
|
|
20
|
+
* that failed settles like one that worked, because ingestion must never see a
|
|
21
|
+
* transcription fail. An id already queued or in flight hands back the promise
|
|
22
|
+
* already outstanding, so two enqueues of one id share one settle.
|
|
23
|
+
*/
|
|
16
24
|
enqueue(id) {
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
const outstanding = this.settling.get(id);
|
|
26
|
+
if (outstanding !== undefined)
|
|
27
|
+
return outstanding.promise;
|
|
28
|
+
let settle;
|
|
29
|
+
const promise = new Promise((resolve) => {
|
|
30
|
+
settle = resolve;
|
|
31
|
+
});
|
|
32
|
+
this.settling.set(id, { promise, settle });
|
|
19
33
|
this.pending.push(id);
|
|
20
34
|
if (this.inFlight === null)
|
|
21
35
|
void this.drain();
|
|
36
|
+
return promise;
|
|
22
37
|
}
|
|
23
38
|
get size() {
|
|
24
39
|
return this.pending.length + (this.inFlight === null ? 0 : 1);
|
|
@@ -43,6 +58,9 @@ export class TranscribeQueue {
|
|
|
43
58
|
logError(`transcribe ${id}`, err);
|
|
44
59
|
}
|
|
45
60
|
this.inFlight = null;
|
|
61
|
+
const done = this.settling.get(id);
|
|
62
|
+
this.settling.delete(id);
|
|
63
|
+
done?.settle();
|
|
46
64
|
}
|
|
47
65
|
const waiters = this.waiters;
|
|
48
66
|
this.waiters = [];
|
package/dist/webhook.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* W1 outbound webhook:
|
|
3
|
-
*
|
|
2
|
+
* W1 outbound webhook: three live events (`message_received`, `message_sent`
|
|
3
|
+
* and `connection`), of which only `message_received` is posted unless
|
|
4
|
+
* `WAZAP_WEBHOOK_EVENTS` asks for more. Global URL, secret and event list live
|
|
5
|
+
* in `.env`; an account may override any of the three. Delivery never throws
|
|
4
6
|
* into the WhatsApp or MCP path.
|
|
5
7
|
*/
|
|
6
8
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
@@ -9,13 +11,19 @@ import { WAZAP_VERSION } from "./config.js";
|
|
|
9
11
|
import { WazapError, asWazapError } from "./errors.js";
|
|
10
12
|
import { logError } from "./logger.js";
|
|
11
13
|
import { redact, stripPasted } from "./transcribe/index.js";
|
|
14
|
+
export const WEBHOOK_EVENTS = ["message_received", "message_sent", "connection"];
|
|
15
|
+
/** What `webhook test` posts when `--event` does not name another one. */
|
|
12
16
|
export const WEBHOOK_EVENT = "message_received";
|
|
13
17
|
export const WEBHOOK_TIMEOUT_MS = 10_000;
|
|
14
|
-
export const WEBHOOK_TEXT_MAX =
|
|
18
|
+
export const WEBHOOK_TEXT_MAX = 2000;
|
|
15
19
|
export const WEBHOOK_RETRY_DELAYS_MS = [200, 500];
|
|
16
20
|
export const WEBHOOK_ON_FIX = "run `wazap config webhook on`";
|
|
17
21
|
export const WEBHOOK_URL_FIX = "set WAZAP_WEBHOOK_URL to an https:// URL, or http:// on 127.0.0.1";
|
|
18
22
|
export const WEBHOOK_TEST_FIX = "run `wazap webhook test`";
|
|
23
|
+
export const WEBHOOK_EVENT_FIX = `pass one of ${WEBHOOK_EVENTS.join(", ")}`;
|
|
24
|
+
export const WEBHOOK_EVENTS_DEFAULT = ["message_received"];
|
|
25
|
+
export const WEBHOOK_EVENTS_FIX = `set WAZAP_WEBHOOK_EVENTS to all or a comma-separated list of ${WEBHOOK_EVENTS.join(", ")}`;
|
|
26
|
+
export const WEBHOOK_KINDS = ["text", "audio", "image", "other"];
|
|
19
27
|
const OFF = new Set(["", "off", "0", "no", "none", "false"]);
|
|
20
28
|
const ON = new Set(["on", "1", "true", "yes"]);
|
|
21
29
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
@@ -36,6 +44,8 @@ export function readWebhookSettings(env = process.env, override = {}) {
|
|
|
36
44
|
const overrideSecret = stripPasted(override.secret ?? "");
|
|
37
45
|
const urlRaw = (overrideUrl || stripPasted(env.WAZAP_WEBHOOK_URL ?? "")).replace(/\/+$/, "");
|
|
38
46
|
const secret = overrideSecret || stripPasted(env.WAZAP_WEBHOOK_SECRET ?? "");
|
|
47
|
+
const overrideEvents = stripPasted(override.events ?? "");
|
|
48
|
+
const eventsRaw = overrideEvents || stripPasted(env.WAZAP_WEBHOOK_EVENTS ?? "");
|
|
39
49
|
const missingUrl = urlRaw === "";
|
|
40
50
|
const missingSecret = secret === "";
|
|
41
51
|
if (missingUrl && missingSecret) {
|
|
@@ -46,7 +56,7 @@ export function readWebhookSettings(env = process.env, override = {}) {
|
|
|
46
56
|
if (missingSecret)
|
|
47
57
|
return { kind: "invalid", detail: "on without a secret", fix: WEBHOOK_ON_FIX };
|
|
48
58
|
try {
|
|
49
|
-
return { kind: "ready", url: requireWebhookUrl(urlRaw), secret };
|
|
59
|
+
return { kind: "ready", url: requireWebhookUrl(urlRaw), secret, events: parseWebhookEvents(eventsRaw) };
|
|
50
60
|
}
|
|
51
61
|
catch (err) {
|
|
52
62
|
const failure = asWazapError(err);
|
|
@@ -84,21 +94,129 @@ export function webhookSignatureMatches(body, secret, header) {
|
|
|
84
94
|
}
|
|
85
95
|
export function previewText(text) {
|
|
86
96
|
if (text.length <= WEBHOOK_TEXT_MAX)
|
|
87
|
-
return text;
|
|
88
|
-
return `${text.slice(0, WEBHOOK_TEXT_MAX - 1)}
|
|
97
|
+
return { text, truncated: false };
|
|
98
|
+
return { text: `${text.slice(0, WEBHOOK_TEXT_MAX - 1)}…`, truncated: true };
|
|
89
99
|
}
|
|
90
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Every message type collapses into one of four buckets a consumer can switch
|
|
102
|
+
* on. A recorded note (`voice`) and an attached file (`audio`) share `audio`,
|
|
103
|
+
* because both carry speech and both can arrive transcribed.
|
|
104
|
+
*/
|
|
105
|
+
const KIND_BY_TYPE = {
|
|
106
|
+
text: "text",
|
|
107
|
+
image: "image",
|
|
108
|
+
audio: "audio",
|
|
109
|
+
voice: "audio",
|
|
110
|
+
video: "other",
|
|
111
|
+
document: "other",
|
|
112
|
+
sticker: "other",
|
|
113
|
+
location: "other",
|
|
114
|
+
contact: "other",
|
|
115
|
+
poll: "other",
|
|
116
|
+
reaction: "other",
|
|
117
|
+
deleted: "other",
|
|
118
|
+
view_once: "other",
|
|
119
|
+
call: "other",
|
|
120
|
+
system: "other",
|
|
121
|
+
unknown: "other",
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* `--event` as typed on the command line. The one place a string becomes an
|
|
125
|
+
* event, so nothing downstream has to widen the union back to `string`.
|
|
126
|
+
*/
|
|
127
|
+
export function parseWebhookEvent(raw) {
|
|
128
|
+
if (raw === undefined)
|
|
129
|
+
return WEBHOOK_EVENT;
|
|
130
|
+
const value = raw.trim();
|
|
131
|
+
const known = WEBHOOK_EVENTS.find((event) => event === value);
|
|
132
|
+
if (known === undefined) {
|
|
133
|
+
throw new WazapError("INVALID_ID", `Unknown webhook event "${raw}".`, WEBHOOK_EVENT_FIX);
|
|
134
|
+
}
|
|
135
|
+
return known;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `WAZAP_WEBHOOK_EVENTS` as an operator typed it. An unset list is the 0.16.0
|
|
139
|
+
* set, so a consumer that answers every POST without reading `event` keeps
|
|
140
|
+
* hearing only what it already handled.
|
|
141
|
+
*/
|
|
142
|
+
export function parseWebhookEvents(raw) {
|
|
143
|
+
const tokens = raw.split(",").map((token) => token.trim()).filter((token) => token !== "");
|
|
144
|
+
if (tokens.length === 0)
|
|
145
|
+
return WEBHOOK_EVENTS_DEFAULT;
|
|
146
|
+
const wanted = new Set();
|
|
147
|
+
for (const token of tokens) {
|
|
148
|
+
const value = token.toLowerCase();
|
|
149
|
+
if (value === "all") {
|
|
150
|
+
for (const event of WEBHOOK_EVENTS)
|
|
151
|
+
wanted.add(event);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const known = WEBHOOK_EVENTS.find((event) => event === value);
|
|
155
|
+
if (known === undefined) {
|
|
156
|
+
throw new WazapError("INVALID_ID", `Unknown webhook event "${token}".`, WEBHOOK_EVENTS_FIX);
|
|
157
|
+
}
|
|
158
|
+
wanted.add(known);
|
|
159
|
+
}
|
|
160
|
+
return WEBHOOK_EVENTS.filter((event) => wanted.has(event));
|
|
161
|
+
}
|
|
162
|
+
export function webhookKind(type) {
|
|
163
|
+
return KIND_BY_TYPE[type];
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `null` is a state nobody outside wazap can act on, so it posts nothing:
|
|
167
|
+
* `linking` and `connecting` are steps on the way to `connected`, and
|
|
168
|
+
* `not_linked` is the state before any credentials exist. Every credential
|
|
169
|
+
* failure collapses to `expired`, the one thing a consumer does something about.
|
|
170
|
+
*/
|
|
171
|
+
const CONNECTION_STATUS = {
|
|
172
|
+
not_linked: null,
|
|
173
|
+
linking: null,
|
|
174
|
+
connecting: null,
|
|
175
|
+
connected: "linked",
|
|
176
|
+
disconnected: "disconnected",
|
|
177
|
+
logged_out: "expired",
|
|
178
|
+
session_corrupt: "expired",
|
|
179
|
+
auth_failure: "expired",
|
|
180
|
+
};
|
|
181
|
+
export function webhookConnectionStatus(status) {
|
|
182
|
+
return CONNECTION_STATUS[status];
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The same instant as the local-offset `ts`, never a fresh read of the clock. The
|
|
186
|
+
* instant is the sender's to state, and a peer that states a nonsense one would
|
|
187
|
+
* otherwise cost the whole delivery, so an unparseable `ts` costs this one field.
|
|
188
|
+
*/
|
|
189
|
+
function utcTimestamp(iso) {
|
|
190
|
+
const at = new Date(iso);
|
|
191
|
+
return Number.isNaN(at.getTime()) ? new Date().toISOString() : at.toISOString();
|
|
192
|
+
}
|
|
193
|
+
export function asWebhookPayload({ event, view, account, isSelfChat }) {
|
|
194
|
+
const { text, truncated } = previewText(view.transcript ?? view.text);
|
|
91
195
|
return {
|
|
92
|
-
event
|
|
196
|
+
event,
|
|
93
197
|
from: view.sender.phone ?? view.sender.id,
|
|
94
198
|
chat_id: view.chat_id,
|
|
95
199
|
ts: view.timestamp,
|
|
96
|
-
|
|
200
|
+
timestamp: utcTimestamp(view.timestamp),
|
|
201
|
+
text,
|
|
202
|
+
truncated,
|
|
203
|
+
kind: webhookKind(view.type),
|
|
204
|
+
from_me: view.from_me,
|
|
205
|
+
is_self_chat: isSelfChat,
|
|
97
206
|
message_id: view.message_id,
|
|
98
207
|
account_id: account.id,
|
|
99
208
|
account_name: account.name,
|
|
100
209
|
};
|
|
101
210
|
}
|
|
211
|
+
export function asConnectionPayload({ status, account, at }) {
|
|
212
|
+
return {
|
|
213
|
+
event: "connection",
|
|
214
|
+
status,
|
|
215
|
+
timestamp: new Date(at).toISOString(),
|
|
216
|
+
account_id: account.id,
|
|
217
|
+
account_name: account.name,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
102
220
|
export function webhookInfo(settings, lastError) {
|
|
103
221
|
switch (settings.kind) {
|
|
104
222
|
case "off":
|
|
@@ -113,7 +231,7 @@ export function webhookInfo(settings, lastError) {
|
|
|
113
231
|
}
|
|
114
232
|
}
|
|
115
233
|
}
|
|
116
|
-
/** Posts
|
|
234
|
+
/** Posts an event when the webhook is on and valid. Never throws. */
|
|
117
235
|
export class WebhookSink {
|
|
118
236
|
env;
|
|
119
237
|
lastError = null;
|
|
@@ -130,34 +248,51 @@ export class WebhookSink {
|
|
|
130
248
|
return readWebhookSettings(this.env, {
|
|
131
249
|
url: this.account?.webhook_url,
|
|
132
250
|
secret: this.account?.webhook_secret,
|
|
251
|
+
events: this.account?.webhook_events,
|
|
133
252
|
});
|
|
134
253
|
}
|
|
135
254
|
info() {
|
|
136
255
|
return webhookInfo(this.settings(), this.lastError);
|
|
137
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* True only when the consumer accepted the POST, so a caller whose event has no
|
|
259
|
+
* later transition to recover with can tell a delivery from a drop. Still never
|
|
260
|
+
* throws.
|
|
261
|
+
*/
|
|
138
262
|
async notify(payload) {
|
|
139
263
|
try {
|
|
140
264
|
const settings = this.settings();
|
|
141
265
|
if (settings.kind !== "ready")
|
|
142
|
-
return;
|
|
143
|
-
|
|
266
|
+
return false;
|
|
267
|
+
if (!settings.events.includes(payload.event))
|
|
268
|
+
return false;
|
|
269
|
+
return (await this.postEvent(payload, settings)).ok;
|
|
144
270
|
}
|
|
145
271
|
catch (err) {
|
|
146
272
|
const settings = this.settings();
|
|
147
273
|
const secret = settings.kind === "ready" ? settings.secret : "";
|
|
148
274
|
this.lastError = redact(err instanceof Error ? err.message : String(err), secret);
|
|
149
275
|
logError("webhook", this.lastError);
|
|
276
|
+
return false;
|
|
150
277
|
}
|
|
151
278
|
}
|
|
152
|
-
async sendTest() {
|
|
279
|
+
async sendTest(event = WEBHOOK_EVENT) {
|
|
153
280
|
const settings = this.settings();
|
|
154
281
|
switch (settings.kind) {
|
|
155
282
|
case "off":
|
|
156
283
|
return { ok: false, error: "Webhook is off.", fix: WEBHOOK_ON_FIX };
|
|
157
284
|
case "invalid":
|
|
158
285
|
return { ok: false, error: settings.detail, fix: settings.fix };
|
|
159
|
-
case "ready":
|
|
160
|
-
|
|
286
|
+
case "ready": {
|
|
287
|
+
if (!settings.events.includes(event)) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
error: `Webhook event "${event}" is not enabled.`,
|
|
291
|
+
fix: enableEventFix(settings.events, event, this.account),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return this.postEvent(testPayload(event, this.account), settings);
|
|
295
|
+
}
|
|
161
296
|
default: {
|
|
162
297
|
const _exhaustive = settings;
|
|
163
298
|
return _exhaustive;
|
|
@@ -169,7 +304,7 @@ export class WebhookSink {
|
|
|
169
304
|
const attempts = 1 + this.retryDelays.length;
|
|
170
305
|
let last = { ok: false, error: "webhook POST failed", fix: "check the webhook URL is reachable and returns 2xx" };
|
|
171
306
|
for (let i = 0; i < attempts; i++) {
|
|
172
|
-
last = await this.postOnce(body, settings);
|
|
307
|
+
last = await this.postOnce(body, payload.event, settings);
|
|
173
308
|
if (last.ok) {
|
|
174
309
|
this.lastError = null;
|
|
175
310
|
return last;
|
|
@@ -185,14 +320,14 @@ export class WebhookSink {
|
|
|
185
320
|
logError("webhook", last.error);
|
|
186
321
|
return last;
|
|
187
322
|
}
|
|
188
|
-
async postOnce(body, settings) {
|
|
323
|
+
async postOnce(body, event, settings) {
|
|
189
324
|
try {
|
|
190
325
|
const response = await this.post(settings.url, {
|
|
191
326
|
method: "POST",
|
|
192
327
|
headers: {
|
|
193
328
|
"content-type": "application/json",
|
|
194
329
|
"user-agent": `wazap/${WAZAP_VERSION}`,
|
|
195
|
-
"x-wazap-event":
|
|
330
|
+
"x-wazap-event": event,
|
|
196
331
|
"x-wazap-signature": webhookSignature(body, settings.secret),
|
|
197
332
|
},
|
|
198
333
|
body,
|
|
@@ -208,16 +343,31 @@ export class WebhookSink {
|
|
|
208
343
|
}
|
|
209
344
|
}
|
|
210
345
|
}
|
|
211
|
-
|
|
346
|
+
/** A list is set whole, so enabling one event means naming the ones already on. */
|
|
347
|
+
function enableEventFix(active, event, account) {
|
|
348
|
+
const both = WEBHOOK_EVENTS.filter((known) => active.includes(known) || known === event).join(",");
|
|
349
|
+
if (account?.webhook_events === undefined)
|
|
350
|
+
return `set WAZAP_WEBHOOK_EVENTS=${both}, or all`;
|
|
351
|
+
return `set webhook_events for "${account.id}" in accounts.json to ${both}, or all`;
|
|
352
|
+
}
|
|
353
|
+
function testPayload(event, account) {
|
|
354
|
+
const now = new Date().toISOString();
|
|
355
|
+
const named = { account_id: account?.id ?? "default", account_name: account?.name ?? "default" };
|
|
356
|
+
if (event === "connection")
|
|
357
|
+
return { event, status: "linked", timestamp: now, ...named };
|
|
212
358
|
return {
|
|
213
|
-
event
|
|
359
|
+
event,
|
|
214
360
|
from: "wazap",
|
|
215
361
|
chat_id: "test@s.whatsapp.net",
|
|
216
|
-
ts:
|
|
362
|
+
ts: now,
|
|
363
|
+
timestamp: now,
|
|
217
364
|
text: "wazap webhook test",
|
|
365
|
+
truncated: false,
|
|
366
|
+
kind: "text",
|
|
367
|
+
from_me: event === "message_sent",
|
|
368
|
+
is_self_chat: false,
|
|
218
369
|
message_id: "test",
|
|
219
|
-
|
|
220
|
-
account_name: account?.name ?? "default",
|
|
370
|
+
...named,
|
|
221
371
|
};
|
|
222
372
|
}
|
|
223
373
|
function hostOf(url) {
|
package/dist/whatsapp.js
CHANGED
|
@@ -21,13 +21,14 @@ import { Notes } from "./notes.js";
|
|
|
21
21
|
import { asGifMedia, assertMediaSource, describe, loadMedia, loadProfilePicture, mediaContent, mediaFilename } from "./outgoing-media.js";
|
|
22
22
|
import { makePreview, videoFrame } from "./previews.js";
|
|
23
23
|
import { decodeMessage, encode, Store } from "./store.js";
|
|
24
|
-
import { buildMessageView, callInfo, formatAge, isCallPlaceholder, isControlMessage, isStubEvent,
|
|
24
|
+
import { buildMessageView, callInfo, formatAge, isCallPlaceholder, isControlMessage, isStubEvent, isUserMessage, isoWithOffset, mediaInfo, mentionedJids, messageIdFor, messageText, messageTimestampMs, messageType, protoNumber, quotedSenderJid, reactionOf, thumbnailOf, viewText, voiceSeconds, } from "./messages.js";
|
|
25
25
|
import { PAIRING_TIMEOUT_MS, WA_BROWSER, prettyCode, startPairing } from "./pairing.js";
|
|
26
26
|
import { readTranscribeSettings, transcribeFile, TranscribeQueue, transcribeReady, } from "./transcribe/index.js";
|
|
27
27
|
import { DraftStore } from "./drafts.js";
|
|
28
28
|
import { RateLimiter } from "./ratelimit.js";
|
|
29
29
|
import { maskNumber } from "./ui.js";
|
|
30
|
-
import {
|
|
30
|
+
import { SentIds } from "./sent-ids.js";
|
|
31
|
+
import { WebhookSink, asConnectionPayload, asWebhookPayload, webhookConnectionStatus, } from "./webhook.js";
|
|
31
32
|
/** Reconnect pacing. A closed socket used to be retried instantly, which turns
|
|
32
33
|
* any persistent rejection into a login storm — WhatsApp answers that by
|
|
33
34
|
* throttling the account and refusing to link *any* new device to it, phone
|
|
@@ -70,6 +71,8 @@ const CALL_DEDUPE_SCAN = 20;
|
|
|
70
71
|
const HISTORY_STORE_CAP_PER_CHAT = 2_000;
|
|
71
72
|
/** Ten minutes of speech. Past that, auto-transcribing is a bill nobody asked for. */
|
|
72
73
|
const AUTO_TRANSCRIBE_MAX_SECONDS = 600;
|
|
74
|
+
/** How long a message event waits for the transcript of the voice note it carries. */
|
|
75
|
+
const WEBHOOK_TRANSCRIPT_WAIT_MS = 60_000;
|
|
73
76
|
const DIR_MODE = 0o700;
|
|
74
77
|
const FILE_MODE = 0o600;
|
|
75
78
|
/**
|
|
@@ -188,6 +191,12 @@ export class WhatsAppService {
|
|
|
188
191
|
drafts = new DraftStore();
|
|
189
192
|
writes;
|
|
190
193
|
webhook;
|
|
194
|
+
/** Sends of our own, so their `fromMe` echo is never announced as `message_sent`. */
|
|
195
|
+
sentByWazap = new SentIds();
|
|
196
|
+
/** The last status a consumer was told, so several internal states collapse into one event. */
|
|
197
|
+
lastWebhookStatus = null;
|
|
198
|
+
/** Connection posts run one at a time, so a consumer sees the order the link moved in. */
|
|
199
|
+
connectionPosts = Promise.resolve();
|
|
191
200
|
accountRecord;
|
|
192
201
|
effectiveReadOnly;
|
|
193
202
|
effectiveRateLimit;
|
|
@@ -385,6 +394,36 @@ export class WhatsAppService {
|
|
|
385
394
|
return;
|
|
386
395
|
this.status = next;
|
|
387
396
|
this.statusSince = Date.now();
|
|
397
|
+
this.queueConnectionWebhook(next);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Several internal states map to one thing a consumer acts on, so the guard is
|
|
401
|
+
* on the mapped status, and it advances only on a post the consumer actually
|
|
402
|
+
* received: an endpoint that was down for `expired` hears it on the next
|
|
403
|
+
* change instead of never, since re-linking needs a human and there is no
|
|
404
|
+
* later transition to recover with. One chain, because a post can occupy half
|
|
405
|
+
* a minute in retries and a flapping link would otherwise land `linked` and
|
|
406
|
+
* `disconnected` out of order. Running the guard inside the chain is what makes
|
|
407
|
+
* "only on change" true of what arrives rather than of what was attempted. The
|
|
408
|
+
* chain rests on `notify` never throwing, so it also catches: a rejection here
|
|
409
|
+
* would silence every later change instead of one.
|
|
410
|
+
*/
|
|
411
|
+
queueConnectionWebhook(status) {
|
|
412
|
+
const mapped = webhookConnectionStatus(status);
|
|
413
|
+
if (mapped === null)
|
|
414
|
+
return;
|
|
415
|
+
const at = this.statusSince;
|
|
416
|
+
this.connectionPosts = this.connectionPosts
|
|
417
|
+
.then(async () => {
|
|
418
|
+
if (mapped === this.lastWebhookStatus)
|
|
419
|
+
return;
|
|
420
|
+
if (this.stopped || this.webhook.settings().kind !== "ready")
|
|
421
|
+
return;
|
|
422
|
+
if (await this.webhook.notify(asConnectionPayload({ status: mapped, account: this.accountRecord, at }))) {
|
|
423
|
+
this.lastWebhookStatus = mapped;
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
.catch((err) => logError("webhook", err));
|
|
388
427
|
}
|
|
389
428
|
getStatus() {
|
|
390
429
|
const inboundAt = this.latestInboundAt();
|
|
@@ -1459,8 +1498,8 @@ export class WhatsAppService {
|
|
|
1459
1498
|
continue;
|
|
1460
1499
|
this.lastInboundAt = Math.max(this.lastInboundAt ?? 0, messageTimestampMs(raw));
|
|
1461
1500
|
}
|
|
1462
|
-
this.queueTranscripts(stored);
|
|
1463
|
-
this.queueWebhook(stored);
|
|
1501
|
+
const transcribing = this.queueTranscripts(stored);
|
|
1502
|
+
this.queueWebhook(stored, transcribing);
|
|
1464
1503
|
this.noteArrivals(stored);
|
|
1465
1504
|
}
|
|
1466
1505
|
void this.appendHistory(stored);
|
|
@@ -2023,21 +2062,26 @@ export class WhatsAppService {
|
|
|
2023
2062
|
return this.transcribe;
|
|
2024
2063
|
}
|
|
2025
2064
|
/**
|
|
2026
|
-
* Live
|
|
2027
|
-
*
|
|
2028
|
-
*
|
|
2029
|
-
*
|
|
2065
|
+
* Live messages both ways, same notify gate as transcription: a history sync
|
|
2066
|
+
* must not POST the backlog, and stubs or system notices are not events. That
|
|
2067
|
+
* gate is also what keeps wazap's own sends quiet in production, since Baileys
|
|
2068
|
+
* re-emits a local send as an `append`; `sentByWazap` is the id-level backstop
|
|
2069
|
+
* for an echo that does arrive as `notify`. Failures stay on
|
|
2070
|
+
* `webhook.last_error` and never reject this path.
|
|
2030
2071
|
*/
|
|
2031
|
-
queueWebhook(arrived) {
|
|
2072
|
+
queueWebhook(arrived, transcribing) {
|
|
2032
2073
|
if (this.stopped || this.webhook.settings().kind !== "ready")
|
|
2033
2074
|
return;
|
|
2034
2075
|
for (const raw of arrived) {
|
|
2035
|
-
if (!
|
|
2076
|
+
if (!isUserMessage(raw))
|
|
2036
2077
|
continue;
|
|
2037
2078
|
try {
|
|
2038
2079
|
const jid = this.canonical(raw.key.remoteJid ?? "");
|
|
2039
2080
|
const sid = messageIdFor(raw.key, jid);
|
|
2040
|
-
|
|
2081
|
+
if (raw.key.fromMe && raw.key.id && this.sentByWazap.has(raw.key.id))
|
|
2082
|
+
continue;
|
|
2083
|
+
const event = raw.key.fromMe ? "message_sent" : "message_received";
|
|
2084
|
+
void this.postMessageEvent({ sid, jid, event, transcript: transcribing.get(sid) }).catch((err) => {
|
|
2041
2085
|
logError("webhook", err);
|
|
2042
2086
|
});
|
|
2043
2087
|
}
|
|
@@ -2046,6 +2090,29 @@ export class WhatsAppService {
|
|
|
2046
2090
|
}
|
|
2047
2091
|
}
|
|
2048
2092
|
}
|
|
2093
|
+
/**
|
|
2094
|
+
* A transcript lands after ingestion returns, so a message that just went onto
|
|
2095
|
+
* the transcribe queue waits for it, bounded, before the view is built:
|
|
2096
|
+
* `viewOf` reads `store.transcripts` fresh, so the wait is the whole reason a
|
|
2097
|
+
* voice note's webhook can carry its words. The wait is on that one message's
|
|
2098
|
+
* transcript, never on the queue, so a note is never held for the notes behind
|
|
2099
|
+
* it. The timer is unreferenced so it can never hold the process open.
|
|
2100
|
+
*/
|
|
2101
|
+
async postMessageEvent(args) {
|
|
2102
|
+
if (args.transcript !== undefined) {
|
|
2103
|
+
await Promise.race([args.transcript, sleep(WEBHOOK_TRANSCRIPT_WAIT_MS, undefined, { ref: false })]);
|
|
2104
|
+
}
|
|
2105
|
+
if (this.stopped) {
|
|
2106
|
+
logError("webhook", `dropped ${args.event} for ${args.sid}: the service stopped while waiting for a transcript`);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
await this.webhook.notify(asWebhookPayload({
|
|
2110
|
+
event: args.event,
|
|
2111
|
+
view: this.viewOf(args.sid, args.jid),
|
|
2112
|
+
account: this.accountRecord,
|
|
2113
|
+
isSelfChat: this.isMe(args.jid),
|
|
2114
|
+
}));
|
|
2115
|
+
}
|
|
2049
2116
|
/**
|
|
2050
2117
|
* Only what genuinely arrived, which is why this hangs off the notify branch
|
|
2051
2118
|
* rather than off ingestMessages: a history sync replays a backlog, and
|
|
@@ -2053,11 +2120,14 @@ export class WhatsAppService {
|
|
|
2053
2120
|
* only, and only ones whose length WhatsApp stated and kept short, since an
|
|
2054
2121
|
* audio file is something the sender chose to attach and a recording of
|
|
2055
2122
|
* unknown length is unbounded. Anything skipped here is still one
|
|
2056
|
-
* transcribe_audio call away. A service on its way out starts nothing.
|
|
2123
|
+
* transcribe_audio call away. A service on its way out starts nothing. Each
|
|
2124
|
+
* sid it enqueued carries the promise that settles when that one transcript
|
|
2125
|
+
* does, which is what a held webhook event waits on.
|
|
2057
2126
|
*/
|
|
2058
2127
|
queueTranscripts(arrived) {
|
|
2128
|
+
const queued = new Map();
|
|
2059
2129
|
if (this.stopped || this.transcribeQueue === null)
|
|
2060
|
-
return;
|
|
2130
|
+
return queued;
|
|
2061
2131
|
for (const raw of arrived) {
|
|
2062
2132
|
if (raw.key.fromMe || messageType(raw) !== "voice")
|
|
2063
2133
|
continue;
|
|
@@ -2067,8 +2137,9 @@ export class WhatsAppService {
|
|
|
2067
2137
|
const sid = messageIdFor(raw.key, this.canonical(raw.key.remoteJid ?? ""));
|
|
2068
2138
|
if (this.store.transcripts.has(sid))
|
|
2069
2139
|
continue;
|
|
2070
|
-
this.transcribeQueue.enqueue(sid);
|
|
2140
|
+
queued.set(sid, this.transcribeQueue.enqueue(sid));
|
|
2071
2141
|
}
|
|
2142
|
+
return queued;
|
|
2072
2143
|
}
|
|
2073
2144
|
messageOrThrow(messageId) {
|
|
2074
2145
|
const raw = this.store.messages.get(messageId);
|
|
@@ -2238,6 +2309,8 @@ export class WhatsAppService {
|
|
|
2238
2309
|
return { message_id: `unknown_${jid}_${randomUUID()}`, chat_id: jid, text, timestamp: isoWithOffset(Date.now()) };
|
|
2239
2310
|
}
|
|
2240
2311
|
const sid = messageIdFor(sent.key, jid);
|
|
2312
|
+
if (sent.key.id)
|
|
2313
|
+
this.sentByWazap.note(sent.key.id);
|
|
2241
2314
|
this.store.putMessage(sid, jid, sent);
|
|
2242
2315
|
this.markStoreDirty();
|
|
2243
2316
|
return { message_id: sid, chat_id: jid, text, timestamp: isoWithOffset(messageTimestampMs(sent)) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wazap-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"mcpName": "io.github.razvangirgiz/wazap",
|
|
5
5
|
"description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, several accounts, 33 tools, stdio or HTTP.",
|
|
6
6
|
"license": "MIT",
|