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 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 secret in `accounts.json`
549
- (`webhook_url`, `webhook_secret`). `wazap webhook test --account work` posts
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
- A live inbound message can POST to one URL. Off by default. History sync
708
- is not posted. The only event is `message_received`.
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` and
720
- `webhook_secret` in `accounts.json`; those win over the global URL and secret.
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": "+15550100",
767
+ "from": "15550100",
730
768
  "chat_id": "15550100@s.whatsapp.net",
731
- "ts": "2026-09-08T14:00:00+00:00",
732
- "text": "hello, or a short preview",
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 live inbound messages to the webhook URL. |
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 { ...record, ...webhookFields(value.id, value.webhook_url, value.webhook_secret, ` in ${file}`) };
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>] POST a test message_received to the configured webhook
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, WAZAP_WEBHOOK_SECRET.
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
- * A person sent this inbound. Stubs, protocol machinery and system notices
327
- * are not `message_received`.
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 isUserInboundMessage(raw) {
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";
@@ -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 = { url: selected.account.webhook_url, secret: selected.account.webhook_secret };
158
- const source = override.url !== undefined || override.secret !== undefined ? "accounts.json" : config.sources.webhook;
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 [`webhook: on (${new URL(settings.url).host}) (${source})`, `secret: ${maskKey(settings.secret)}`];
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 — live inbound messages are not posted anywhere. Turn it on with `wazap config webhook on`."));
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} — live inbound messages POST to ${new URL(url).host} as message_received.`));
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 — live inbound messages POST to ${new URL(url).host} as message_received.`));
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;
@@ -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
- /** Ignores an id already queued or in flight. */
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
- if (this.inFlight === id || this.pending.includes(id))
18
- return;
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: one live event (`message_received`). Global URL and
3
- * secret live in `.env`; an account may override either. Delivery never throws
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 = 500;
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
- export function asWebhookPayload(view, account) {
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: WEBHOOK_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
- text: previewText(view.text),
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 `message_received` when the webhook is on and valid. Never throws. */
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
- await this.postEvent(payload, settings);
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
- return this.postEvent(testPayload(this.account), settings);
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": WEBHOOK_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
- function testPayload(account) {
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: WEBHOOK_EVENT,
359
+ event,
214
360
  from: "wazap",
215
361
  chat_id: "test@s.whatsapp.net",
216
- ts: new Date().toISOString(),
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
- account_id: account?.id ?? "default",
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, isUserInboundMessage, isoWithOffset, mediaInfo, mentionedJids, messageIdFor, messageText, messageTimestampMs, messageType, protoNumber, quotedSenderJid, reactionOf, thumbnailOf, viewText, voiceSeconds, } from "./messages.js";
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 { WebhookSink, asWebhookPayload } from "./webhook.js";
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 inbound a person sent, same notify gate as transcription: a history
2027
- * sync must not POST the backlog, and stubs or system notices are not
2028
- * `message_received`. Failures stay on `webhook.last_error` and never
2029
- * reject this path.
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 (!isUserInboundMessage(raw))
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
- void this.webhook.notify(asWebhookPayload(this.viewOf(sid, jid), this.accountRecord)).catch((err) => {
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.16.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",