@wirecat/whatsapp-cli 0.4.0 → 0.7.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.
Files changed (135) hide show
  1. package/README.md +9 -3
  2. package/dist/adapter/adapter.d.ts +4 -2
  3. package/dist/adapter/adapter.d.ts.map +1 -1
  4. package/dist/adapter/adapter.js +20 -406
  5. package/dist/adapter/adapter.js.map +1 -1
  6. package/dist/adapter/channels.d.ts.map +1 -1
  7. package/dist/adapter/channels.js.map +1 -1
  8. package/dist/adapter/comments.d.ts +5 -0
  9. package/dist/adapter/comments.d.ts.map +1 -0
  10. package/dist/adapter/comments.js +14 -0
  11. package/dist/adapter/comments.js.map +1 -0
  12. package/dist/adapter/discovery.d.ts +5 -0
  13. package/dist/adapter/discovery.d.ts.map +1 -0
  14. package/dist/adapter/discovery.js +28 -0
  15. package/dist/adapter/discovery.js.map +1 -0
  16. package/dist/adapter/errors.d.ts +1 -1
  17. package/dist/adapter/errors.d.ts.map +1 -1
  18. package/dist/adapter/errors.js +4 -2
  19. package/dist/adapter/errors.js.map +1 -1
  20. package/dist/adapter/groups.d.ts.map +1 -1
  21. package/dist/adapter/groups.js +1 -1
  22. package/dist/adapter/groups.js.map +1 -1
  23. package/dist/adapter/listen.d.ts +11 -0
  24. package/dist/adapter/listen.d.ts.map +1 -0
  25. package/dist/adapter/listen.js +264 -0
  26. package/dist/adapter/listen.js.map +1 -0
  27. package/dist/adapter/map.d.ts +1 -1
  28. package/dist/adapter/map.d.ts.map +1 -1
  29. package/dist/adapter/map.js +13 -22
  30. package/dist/adapter/map.js.map +1 -1
  31. package/dist/adapter/polls.d.ts +2 -1
  32. package/dist/adapter/polls.d.ts.map +1 -1
  33. package/dist/adapter/polls.js +14 -3
  34. package/dist/adapter/polls.js.map +1 -1
  35. package/dist/adapter/privacy.d.ts.map +1 -1
  36. package/dist/adapter/privacy.js +26 -11
  37. package/dist/adapter/privacy.js.map +1 -1
  38. package/dist/adapter/sends.d.ts +14 -0
  39. package/dist/adapter/sends.d.ts.map +1 -0
  40. package/dist/adapter/sends.js +156 -0
  41. package/dist/adapter/sends.js.map +1 -0
  42. package/dist/adapter/settings.js +4 -1
  43. package/dist/adapter/settings.js.map +1 -1
  44. package/dist/app.d.ts +8 -0
  45. package/dist/app.d.ts.map +1 -1
  46. package/dist/app.js +20 -1
  47. package/dist/app.js.map +1 -1
  48. package/dist/bot/adapter.d.ts +18 -0
  49. package/dist/bot/adapter.d.ts.map +1 -0
  50. package/dist/bot/adapter.js +74 -0
  51. package/dist/bot/adapter.js.map +1 -0
  52. package/dist/bot/generated/definitions.d.ts +3 -0
  53. package/dist/bot/generated/definitions.d.ts.map +1 -0
  54. package/dist/bot/generated/definitions.js +9254 -0
  55. package/dist/bot/generated/definitions.js.map +1 -0
  56. package/dist/bot/generated/manifest.d.ts +3 -0
  57. package/dist/bot/generated/manifest.d.ts.map +1 -0
  58. package/dist/bot/generated/manifest.js +2614 -0
  59. package/dist/bot/generated/manifest.js.map +1 -0
  60. package/dist/bot/generated/schemas.d.ts +803 -0
  61. package/dist/bot/generated/schemas.d.ts.map +1 -0
  62. package/dist/bot/generated/schemas.js +3052 -0
  63. package/dist/bot/generated/schemas.js.map +1 -0
  64. package/dist/bot/generated/types.d.ts +3439 -0
  65. package/dist/bot/generated/types.d.ts.map +1 -0
  66. package/dist/bot/generated/types.js +5 -0
  67. package/dist/bot/generated/types.js.map +1 -0
  68. package/dist/bot/graph.d.ts +53 -0
  69. package/dist/bot/graph.d.ts.map +1 -0
  70. package/dist/bot/graph.js +141 -0
  71. package/dist/bot/graph.js.map +1 -0
  72. package/dist/bot/messaging.d.ts +30 -0
  73. package/dist/bot/messaging.d.ts.map +1 -0
  74. package/dist/bot/messaging.js +124 -0
  75. package/dist/bot/messaging.js.map +1 -0
  76. package/dist/bot/mime.d.ts +3 -0
  77. package/dist/bot/mime.d.ts.map +1 -0
  78. package/dist/bot/mime.js +20 -0
  79. package/dist/bot/mime.js.map +1 -0
  80. package/dist/bot/webhook.d.ts +13 -0
  81. package/dist/bot/webhook.d.ts.map +1 -0
  82. package/dist/bot/webhook.js +148 -0
  83. package/dist/bot/webhook.js.map +1 -0
  84. package/dist/commands/bot-templates.d.ts +9 -0
  85. package/dist/commands/bot-templates.d.ts.map +1 -0
  86. package/dist/commands/bot-templates.js +141 -0
  87. package/dist/commands/bot-templates.js.map +1 -0
  88. package/dist/commands/bot.d.ts +14 -0
  89. package/dist/commands/bot.d.ts.map +1 -0
  90. package/dist/commands/bot.js +208 -0
  91. package/dist/commands/bot.js.map +1 -0
  92. package/dist/commands/session.d.ts +0 -44
  93. package/dist/commands/session.d.ts.map +1 -1
  94. package/dist/commands/session.js +15 -141
  95. package/dist/commands/session.js.map +1 -1
  96. package/dist/commands/setup.d.ts.map +1 -1
  97. package/dist/commands/setup.js +9 -8
  98. package/dist/commands/setup.js.map +1 -1
  99. package/dist/commands/update.d.ts +2 -1
  100. package/dist/commands/update.d.ts.map +1 -1
  101. package/dist/commands/update.js.map +1 -1
  102. package/dist/diagnose.d.ts +6 -41
  103. package/dist/diagnose.d.ts.map +1 -1
  104. package/dist/diagnose.js.map +1 -1
  105. package/dist/engine/channels.d.ts +1 -0
  106. package/dist/engine/channels.d.ts.map +1 -1
  107. package/dist/engine/contract.d.ts +32 -2
  108. package/dist/engine/contract.d.ts.map +1 -1
  109. package/dist/engine/contract.js +9 -1
  110. package/dist/engine/contract.js.map +1 -1
  111. package/dist/engine/discovery.d.ts +34 -0
  112. package/dist/engine/discovery.d.ts.map +1 -0
  113. package/dist/engine/discovery.js +22 -0
  114. package/dist/engine/discovery.js.map +1 -0
  115. package/dist/engine/events.d.ts +1 -1
  116. package/dist/engine/events.d.ts.map +1 -1
  117. package/dist/engine/events.js.map +1 -1
  118. package/dist/engine/go-engine.d.ts.map +1 -1
  119. package/dist/engine/go-engine.js +4 -0
  120. package/dist/engine/go-engine.js.map +1 -1
  121. package/dist/messenger.d.ts +4 -1
  122. package/dist/messenger.d.ts.map +1 -1
  123. package/dist/messenger.js +8 -10
  124. package/dist/messenger.js.map +1 -1
  125. package/dist/program.d.ts.map +1 -1
  126. package/dist/program.js +8 -2
  127. package/dist/program.js.map +1 -1
  128. package/dist/session.d.ts +56 -0
  129. package/dist/session.d.ts.map +1 -0
  130. package/dist/session.js +136 -0
  131. package/dist/session.js.map +1 -0
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/package.json +16 -11
  135. package/skills/whatsapp-cli/SKILL.md +16 -13
package/README.md CHANGED
@@ -45,6 +45,8 @@ You: Yes.
45
45
  **Early.** `wa` is new, and some of what it does has not yet been checked against real WhatsApp. The
46
46
  [backlog](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/dev/BACKLOG.md) lists the checks still to run.
47
47
 
48
+ More: [compared with other tools](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/compare.md) · [on wirecat.dev](https://wirecat.dev/en/docs/wa/compare)
49
+
48
50
  ## Quick start
49
51
 
50
52
  ```sh
@@ -117,11 +119,11 @@ Your agent. Your WhatsApp. Your rules.
117
119
  comes with ready prompts: `/catch-up`, `/review`, `/reply`, `/find` and more.
118
120
 
119
121
  ```sh
120
- wa personal mcp setup claude-code # or: wa personal mcp setup codex
122
+ wa personal mcp add claude-code # or: wa personal mcp add codex
121
123
  wa personal mcp config # the entry for Claude Desktop, Cursor and others
122
124
  ```
123
125
 
124
- The profile can send by default, so `mcp setup` stops first. Check the profile's permissions, then run it again with
126
+ The profile can send by default, so `mcp add` stops first. Check the profile's permissions, then run it again with
125
127
  `--allow-writes`.
126
128
 
127
129
  More: [the MCP server](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/mcp.md) · [from the browser](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/remote.md)
@@ -130,6 +132,8 @@ More: [the MCP server](https://github.com/WireCatLabs/whatsapp-cli/blob/main/doc
130
132
 
131
133
  - **Your own account, as a linked device.** `wa` links the way WhatsApp Web does. It sees your chats, groups and
132
134
  contacts, and speaks as you, not as a business account or a bot.
135
+ - **A business number too, when you have one.** `wa <profile> bot` speaks for a WhatsApp Business number on Meta's
136
+ Cloud API: send, templates, and messages received by webhook through your tunnel ([bot](docs/bot.md)).
133
137
  - **Local-first search.** Everything received is kept on your computer. Every read and search answers from there and
134
138
  never connects.
135
139
  - **Made for agents.** One operation per call, the same JSON shape every time with `--json`, a fixed exit code for
@@ -252,7 +256,7 @@ changing anything; at `ask` the server shows no form, so set up approval in your
252
256
 
253
257
  ```sh
254
258
  claude mcp add wa -- wa personal mcp # Claude Code, by hand
255
- wa personal mcp doctor # check the local MCP server
259
+ wa personal mcp check # check the local MCP server
256
260
  ```
257
261
 
258
262
  **From the browser.** ChatGPT or Claude in the browser reach `wa personal mcp --http --public-url` behind your tunnel,
@@ -357,6 +361,7 @@ Every page is in [`docs/`](https://github.com/WireCatLabs/whatsapp-cli/blob/main
357
361
  | [people](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/people.md) | One person: what the store holds about them, your own names and notes |
358
362
  | [mcp](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/mcp.md) | How do I connect Claude Desktop, Cursor or another client without a terminal? |
359
363
  | [remote](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/remote.md) | ChatGPT or Claude in the browser, through a login and a tunnel |
364
+ | [bot](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/bot.md) | A WhatsApp Business number on Meta's Cloud API: setup, sending, templates, receiving by webhook |
360
365
  | [recipes](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/recipes.md) | What can an agent do for me every day, and how do I run it on a schedule? |
361
366
  | [commands](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/commands.md) | Every command, option and exit code — **generated** from the program |
362
367
  | [configuration](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/configuration.md) | How do I configure common behaviour? |
@@ -365,6 +370,7 @@ Every page is in [`docs/`](https://github.com/WireCatLabs/whatsapp-cli/blob/main
365
370
  | [configuration-reference](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/configuration-reference.md) | Every key, default, scope and variable |
366
371
  | [cli-contract](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/cli-contract.md) | Invocation, output, errors, headless runs, bounds and previews |
367
372
  | [limits](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/limits.md) | Limits, waits and the one connection per device |
373
+ | [compare](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/compare.md) · [web](https://wirecat.dev/en/docs/wa/compare) | How does `wa` compare with wacli, whatsapp-mcp, mudslide and the Cloud API, and when does another tool fit better? |
368
374
  | [diagnostics](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/diagnostics.md) | What did a command do, and what is never recorded? |
369
375
  | [troubleshooting](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/troubleshooting.md) | Something does not work: what the screen says, and what to do |
370
376
  | [security](https://github.com/WireCatLabs/whatsapp-cli/blob/main/docs/security.md) | What does `wa` keep on my computer, and what can go wrong? |
@@ -2,12 +2,14 @@ import { type MarkdownFormatting } from "@wirecat/cli-core";
2
2
  import type { LiveUpdates, MessageEditing, MessageMedia, MessagePins, MessageReactions, MessageStars, MessengerCore, PassiveUpdates, PushedHistory, ReadState, ServerReads, StoredMessages } from "@wirecat/cli-core/cli";
3
3
  import { type WhatsAppEngine } from "../engine/contract.js";
4
4
  import type { Words } from "./checks.js";
5
+ import { type CommentAdapter } from "./comments.js";
6
+ import { type DiscoveryAdapter } from "./discovery.js";
5
7
  import { type GroupAdapter } from "./groups.js";
6
8
  import { type PollAdapter } from "./polls.js";
7
9
  import { type PrivacyAdapter } from "./privacy.js";
8
10
  import { type SettingsAdapter } from "./settings.js";
9
- export type WhatsAppAdapter = MessengerCore & LiveUpdates & PassiveUpdates & PushedHistory & Pick<MessageEditing, "edit" | "delete" | "forward"> & Pick<MessageMedia, "download"> & MessageReactions & MessageStars & ReadState & Pick<ServerReads, "history"> & MarkdownFormatting & PollAdapter & GroupAdapter & PrivacyAdapter & MessagePins & SettingsAdapter;
11
+ export type WhatsAppAdapter = MessengerCore & LiveUpdates & PassiveUpdates & PushedHistory & Pick<MessageEditing, "edit" | "delete" | "forward"> & Pick<MessageMedia, "download"> & MessageReactions & MessageStars & ReadState & Pick<ServerReads, "history"> & MarkdownFormatting & PollAdapter & GroupAdapter & PrivacyAdapter & MessagePins & SettingsAdapter & DiscoveryAdapter & CommentAdapter;
10
12
  export declare const whatsAppAdapter: (engine: WhatsAppEngine, stored?: StoredMessages, words?: Words,
11
13
  /** Where a skipped pushed event is said — a warning, so `--quiet` drops it. The diagnostic stream if not given. */
12
- warn?: (message: string) => void) => WhatsAppAdapter;
14
+ warn?: (message: string) => void, now?: () => number) => WhatsAppAdapter;
13
15
  //# sourceMappingURL=adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/adapter/adapter.ts"],"names":[],"mappings":"AAMA,OAAO,EAGL,KAAK,kBAAkB,EAMxB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,KAAK,EACV,WAAW,EACX,cAAc,EACd,YAAY,EACZ,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,cAAc,EACd,aAAa,EACb,SAAS,EAGT,WAAW,EACX,cAAc,EACf,MAAM,uBAAuB,CAAA;AAE9B,OAAO,EASL,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAA;AAE9B,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAGxC,OAAO,EAAE,KAAK,YAAY,EAAgB,MAAM,aAAa,CAAA;AAc7D,OAAO,EAAE,KAAK,WAAW,EAA2C,MAAM,YAAY,CAAA;AACtF,OAAO,EAAE,KAAK,cAAc,EAAkB,MAAM,cAAc,CAAA;AAClE,OAAO,EAAuB,KAAK,eAAe,EAAkC,MAAM,eAAe,CAAA;AAEzG,MAAM,MAAM,eAAe,GAAG,aAAa,GACzC,WAAW,GACX,cAAc,GACd,aAAa,GACb,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC,GACnD,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,GAC9B,gBAAgB,GAChB,YAAY,GACZ,SAAS,GACT,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,GAC5B,kBAAkB,GAClB,WAAW,GACX,YAAY,GACZ,cAAc,GACd,WAAW,GACX,eAAe,CAAA;AAqMjB,eAAO,MAAM,eAAe,WAClB,cAAc,WACd,cAAc,UACf,KAAK;AACZ,mHAAmH;OAC7G,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,KAC9B,eAqeF,CAAA"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/adapter/adapter.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,kBAAkB,EAIxB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,KAAK,EACV,WAAW,EACX,cAAc,EACd,YAAY,EACZ,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,cAAc,EACd,aAAa,EACb,SAAS,EACT,WAAW,EACX,cAAc,EACf,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAsC,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAE/F,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,KAAK,cAAc,EAAkB,MAAM,eAAe,CAAA;AACnE,OAAO,EAAE,KAAK,gBAAgB,EAAoB,MAAM,gBAAgB,CAAA;AAGxE,OAAO,EAAE,KAAK,YAAY,EAAgB,MAAM,aAAa,CAAA;AAI7D,OAAO,EAAE,KAAK,WAAW,EAAe,MAAM,YAAY,CAAA;AAC1D,OAAO,EAAE,KAAK,cAAc,EAAkB,MAAM,cAAc,CAAA;AAElE,OAAO,EAAuB,KAAK,eAAe,EAAkC,MAAM,eAAe,CAAA;AAEzG,MAAM,MAAM,eAAe,GAAG,aAAa,GACzC,WAAW,GACX,cAAc,GACd,aAAa,GACb,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC,GACnD,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,GAC9B,gBAAgB,GAChB,YAAY,GACZ,SAAS,GACT,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,GAC5B,kBAAkB,GAClB,WAAW,GACX,YAAY,GACZ,cAAc,GACd,WAAW,GACX,eAAe,GACf,gBAAgB,GAChB,cAAc,CAAA;AAoChB,eAAO,MAAM,eAAe,WAClB,cAAc,WACd,cAAc,UACf,KAAK;AACZ,mHAAmH;OAC7G,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,QAC1B,MAAM,MAAM,KAChB,eA+KF,CAAA"}
@@ -1,19 +1,19 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { createReadStream } from "node:fs";
3
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { resolve as absolute, extname, join } from "node:path";
6
2
  import { CliError } from "@wirecat/cli-base";
7
3
  import { bareCheck, parseMarkdownSpans, pickChat, } from "@wirecat/cli-core";
8
- import { EngineError, OLDER_MAX, } from "../engine/contract.js";
9
- import { channelAdapter, channelHistory, channelWrites, isChannel, refuseChannelPost } from "./channels.js";
4
+ import { EngineError, OLDER_MAX } from "../engine/contract.js";
5
+ import { channelAdapter, channelHistory, channelWrites, isChannel } from "./channels.js";
6
+ import { commentAdapter } from "./comments.js";
7
+ import { discoveryAdapter } from "./discovery.js";
10
8
  import { fromEngine } from "./errors.js";
11
9
  import { toWhatsApp } from "./format-markdown.js";
12
10
  import { groupAdapter } from "./groups.js";
13
- import { isFile, sameFile, toAccount, toChat, toChatEvent, toChatUpdate, toMember, toMessage, toPersonReaction, toWaAttachment, } from "./map.js";
11
+ import { listenAdapter } from "./listen.js";
12
+ import { toAccount, toChat, toMember, toMessage } from "./map.js";
14
13
  import { pinAdapter } from "./pins.js";
15
- import { pollAdapter, toHistoryVote, toVoteEvent } from "./polls.js";
14
+ import { pollAdapter } from "./polls.js";
16
15
  import { privacyAdapter } from "./privacy.js";
16
+ import { onDisk, sendAdapter } from "./sends.js";
17
17
  import { AFTER_EVERY_MESSAGE, settingsAdapter, settingsWrite } from "./settings.js";
18
18
  // WhatsApp addresses a message by its sender and by whether it is the account's own, and the engine keeps no
19
19
  // messages, so an edit, a deletion, a reaction or a read receipt looks the message up in cli-core's store first.
@@ -26,8 +26,6 @@ const NOTHING_STORED = {
26
26
  };
27
27
  // whatsmeow's EditWindow: WhatsApp clients ignore an edit that comes later.
28
28
  const EDIT_WINDOW_MS = 20 * 60 * 1000;
29
- /** Options WhatsApp has no way to honour; `noPreview` is always true, since nothing here builds link previews. */
30
- const REFUSED = ["threadId", "markup", "sticker", "spoiler", "captionAbove"];
31
29
  const translated = async (work) => {
32
30
  try {
33
31
  return await work;
@@ -35,117 +33,14 @@ const translated = async (work) => {
35
33
  catch (error) {
36
34
  if (error instanceof EngineError)
37
35
  throw fromEngine(error);
38
- throw error;
36
+ if (error instanceof CliError)
37
+ throw error;
38
+ const failure = new CliError("provider_error", "WhatsApp engine request failed");
39
+ Object.defineProperty(failure, "cause", { value: error });
40
+ throw failure;
39
41
  }
40
42
  };
41
- const hit = (message) => ({ ...message, chatTitle: null });
42
43
  const PHONE_CHAT = /^\d+@s\.whatsapp\.net$/;
43
- const ENDED = {
44
- logged_out: (words) => fromEngine(new EngineError("authentication_error", `WhatsApp logged this device out — log in again with ${words("session.start")}`, "device.logged-out")),
45
- replaced: () => fromEngine(new EngineError("provider_unavailable", "another process took over this WhatsApp login; only one may hold it", "device.replaced")),
46
- banned: (_words, until) => fromEngine(new EngineError("permission_error", "WhatsApp has banned or restricted this account; see the phone", "account.banned", until)),
47
- outdated: () => fromEngine(new EngineError("provider_unavailable", "WhatsApp no longer accepts this version of wa; update it", "client.outdated")),
48
- stopped: () => fromEngine(new EngineError("provider_unavailable", "the WhatsApp engine stopped unexpectedly", "engine.stopped")),
49
- };
50
- const ENDING = new Set([
51
- ...Object.values(ENDED).map((end) => end(bareCheck).details.reason),
52
- "engine.stopped-mid-write",
53
- ]);
54
- /** A failure that means the connection is gone, not that one event was odd: it ends the listening. */
55
- const ends = (error) => (error instanceof EngineError && ENDING.has(error.reason)) ||
56
- (error instanceof CliError && ENDING.has(error.details.reason));
57
- /**
58
- * Hands each event on until `signal` aborts; a connection WhatsApp ended rejects with why, unless `expected` says this
59
- * process asked for that end. Events are handled one at a time in arrival order. One whose handling fails is skipped
60
- * and the next still runs: a stalled queue would keep every later push out of the store until a restart. The first skip
61
- * is said on `warn` at once, kind and code only, never its content; `drained` settles when the handling caught up and
62
- * then says how many were skipped if there were more.
63
- */
64
- const listening = (engine, kinds, signal, words, warn, handle, expected = () => false) => {
65
- let stop = () => { };
66
- let queue = Promise.resolve();
67
- let skipped = 0;
68
- let fail = () => { };
69
- const run = (kind, work) => {
70
- queue = queue.then(work).catch((error) => fail(kind, error));
71
- };
72
- const done = new Promise((resolve, reject) => {
73
- fail = (kind, error) => {
74
- if (ends(error))
75
- return reject(error instanceof EngineError ? fromEngine(error) : error);
76
- skipped += 1;
77
- if (skipped > 1)
78
- return;
79
- const code = error instanceof CliError ? error.code : error instanceof Error ? error.name : "error";
80
- warn(`a pushed ${kind} event could not be handled and was skipped (${code}); the ones after it go on, ` +
81
- "and any more skips are counted when wa stops listening");
82
- };
83
- const unlisten = engine.listen(kinds, (event) => {
84
- if (event.event === "connection" && expected(event.state))
85
- return resolve();
86
- const ended = event.event === "connection" ? ENDED[event.state]?.(words, event.until) : undefined;
87
- if (ended)
88
- reject(ended);
89
- else
90
- run(event.event, () => handle(event));
91
- });
92
- const aborted = () => resolve();
93
- signal.addEventListener("abort", aborted, { once: true });
94
- stop = () => {
95
- unlisten();
96
- signal.removeEventListener("abort", aborted);
97
- };
98
- if (signal.aborted)
99
- resolve();
100
- });
101
- // The caller may still be awaiting the engine's readiness when an ended connection rejects `done`.
102
- done.catch(() => { });
103
- const drained = () => queue.then(() => {
104
- if (skipped > 1)
105
- warn(`${skipped} pushed events could not be handled and were skipped in all`);
106
- });
107
- return { done, stop, drained, run };
108
- };
109
- /** Re-keys that arrive this close together go to cli-core as one batch, up to this many. */
110
- const REKEY_WINDOW_MS = 100;
111
- const REKEY_BATCH = 500;
112
- const VIDEO = new Set([".mp4", ".m4v", ".mov"]);
113
- /** As tg sends them: a photo as a photo, a voice as a voice, a video as a video unless `--as-file`, the rest as files. */
114
- const uploadKind = ({ kind, name, asFile }) => kind === "photo" || kind === "voice" ? kind : !asFile && VIDEO.has(extname(name).toLowerCase()) ? "video" : "document";
115
- /** A folder only this user can read, gone when `work` ends: the engine writes a download into it, or reads bytes made in memory. */
116
- const inPrivateFolder = async (work) => {
117
- const folder = await mkdtemp(join(tmpdir(), "wa-media-"));
118
- try {
119
- return await work(folder);
120
- }
121
- finally {
122
- await rm(folder, { recursive: true, force: true });
123
- }
124
- };
125
- const onDisk = async (source, work) => {
126
- if (source.kind === "file")
127
- return work(absolute(source.path));
128
- return inPrivateFolder(async (folder) => {
129
- const path = join(folder, "upload");
130
- await writeFile(path, source.bytes, { mode: 0o600, flag: "wx" });
131
- return work(path);
132
- });
133
- };
134
- const allOnDisk = async (files, work, placed = []) => {
135
- const [next, ...rest] = files;
136
- if (!next)
137
- return work(placed);
138
- return onDisk(next.upload.source, (path) => allOnDisk(rest, work, [...placed, { ...next, path }]));
139
- };
140
- const quoted = (message) => ({
141
- id: message.id,
142
- senderId: message.senderId,
143
- senderName: message.senderName,
144
- timestamp: message.timestamp,
145
- text: message.text,
146
- attachments: message.attachments,
147
- outgoing: message.outgoing ?? null,
148
- });
149
44
  const keyOf = (chatId, message) => ({
150
45
  chat: chatId,
151
46
  id: message.id,
@@ -154,7 +49,7 @@ const keyOf = (chatId, message) => ({
154
49
  });
155
50
  export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareCheck,
156
51
  /** Where a skipped pushed event is said — a warning, so `--quiet` drops it. The diagnostic stream if not given. */
157
- warn = (line) => process.stderr.write(`${line}\n`)) => {
52
+ warn = (line) => process.stderr.write(`${line}\n`), now = Date.now) => {
158
53
  // A guessed key would make WhatsApp drop the change without a word, so a message the store lacks is refused.
159
54
  const held = async (chatId, messageId) => {
160
55
  const message = await stored.message(chatId, messageId);
@@ -162,178 +57,13 @@ warn = (line) => process.stderr.write(`${line}\n`)) => {
162
57
  throw new CliError("not_found", `that message is not in the local store, and WhatsApp needs its sender to address it; ${words("serve")} keeps the store filled`);
163
58
  return message;
164
59
  };
165
- // Set by `session end`: WhatsApp then ends the connection as logged out, which is the answer, not a failure.
166
- let loggingOut = false;
167
- // Held re-keys of a running `feed`: a live event may name the new id, and must not reach cli-core before them (§5).
168
- const holding = new Set();
169
- const handingOn = (onEvent, signal, expected) => {
170
- const handed = async (event) => {
171
- for (const handOn of holding)
172
- handOn();
173
- if (event.event === "message")
174
- onEvent({ event: "message", message: hit(toMessage(event.message)) });
175
- else if (event.event === "edit")
176
- onEvent({ event: "edit", message: hit(toMessage(event.message)) });
177
- else if (event.event === "delete")
178
- onEvent({
179
- event: "delete",
180
- chatId: event.chat,
181
- chatTitle: null,
182
- messageId: event.id,
183
- });
184
- else if (event.event === "star")
185
- onEvent({
186
- event: "star",
187
- chatId: event.chat,
188
- chatTitle: null,
189
- messageId: event.id,
190
- starred: event.starred,
191
- });
192
- else if (event.event === "reaction")
193
- onEvent({
194
- event: "person_reaction",
195
- chatId: event.reaction.chat,
196
- chatTitle: null,
197
- messageId: event.reaction.message,
198
- reaction: toPersonReaction(event.reaction),
199
- });
200
- else if (event.event === "receipt")
201
- onEvent({
202
- event: "delivery",
203
- chatId: event.receipt.chat,
204
- chatTitle: null,
205
- messageId: event.receipt.id,
206
- delivery: event.receipt.delivery,
207
- at: event.receipt.at,
208
- });
209
- else if (event.event === "vote") {
210
- const vote = await toVoteEvent(event.vote, stored);
211
- if (vote)
212
- onEvent(vote);
213
- }
214
- else if (event.event === "chat_event")
215
- onEvent({
216
- event: "chat_event",
217
- chatId: event.change.chat,
218
- chatTitle: null,
219
- change: toChatEvent(event.change),
220
- });
221
- };
222
- // In arrival order: a vote reads its poll from the store, and nothing after it may overtake it.
223
- const live = listening(engine, ["message", "edit", "delete", "vote", "reaction", "star", "receipt", "chat_event", "connection"], signal, words, warn, handed, expected);
224
- return {
225
- done: live.done,
226
- stop: async () => {
227
- live.stop();
228
- await live.drained();
229
- },
230
- };
231
- };
232
- // A push after a re-link carries the path the file had then, and the store may hold the one the phone uploaded
233
- // again. cli-core keeps a stored ref only when the pushed file has none, so the pushed one is left off when it names
234
- // the same file; another file keeps its own. Only a message with a ref costs a store read; one that fails hands the
235
- // message on as pushed, never loses it.
236
- const withoutKeptRefs = async (message) => {
237
- if (!message.attachments.some((file) => file.providerRef))
238
- return message;
239
- const kept = await stored.message(message.chatId, message.id).catch(() => undefined);
240
- if (!kept?.attachments.some((file) => file.providerRef))
241
- return message;
242
- return {
243
- ...message,
244
- attachments: message.attachments.map((file, position) => {
245
- if (!sameFile(file.providerRef, kept.attachments[position]?.providerRef))
246
- return file;
247
- const { providerRef: _pushed, ...rest } = file;
248
- return rest;
249
- }),
250
- };
251
- };
252
- const pushing = async (onBatch, signal, expected) => {
253
- // cli-core saves each batch's re-keys apart, so thousands at a serve's start must not come one batch each; a push
254
- // hands on the held ones before its own, as they arrived.
255
- let held = [];
256
- let due;
257
- const flush = () => {
258
- clearTimeout(due);
259
- due = undefined;
260
- if (held.length === 0)
261
- return;
262
- const rekeys = held;
263
- held = [];
264
- onBatch({ rekeys });
265
- };
266
- // In arrival order: a push waits for its store reads, and a re-key after it must not overtake it.
267
- const handed = async (event) => {
268
- if (event.event === "rekey") {
269
- held.push({ from: event.from, to: event.to });
270
- if (held.length >= REKEY_BATCH)
271
- flush();
272
- else
273
- due ??= setTimeout(() => pushes.run("rekey", flush), REKEY_WINDOW_MS);
274
- return;
275
- }
276
- if (event.event !== "history")
277
- return;
278
- flush();
279
- const { rekeys, chats, contacts, messages, votes, reactions, events } = event.history;
280
- onBatch({
281
- rekeys: rekeys ?? [],
282
- chats: chats.map(toChatUpdate),
283
- people: contacts.map(toMember),
284
- messages: await Promise.all(messages.map((message) => withoutKeptRefs(toMessage(message)))),
285
- votes: votes.flatMap((vote) => toHistoryVote(vote) ?? []),
286
- reactions: (reactions ?? []).map((reaction) => ({
287
- chatId: reaction.chat,
288
- messageId: reaction.message,
289
- reaction: toPersonReaction(reaction),
290
- })),
291
- events: (events ?? []).map((change) => ({ chatId: change.chat, change: toChatEvent(change) })),
292
- });
293
- };
294
- const pushes = listening(engine, ["history", "rekey", "connection"], signal, words, warn, handed, expected);
295
- const handOn = () => {
296
- try {
297
- flush();
298
- }
299
- catch (error) {
300
- pushes.run("rekey", () => Promise.reject(error));
301
- }
302
- };
303
- holding.add(handOn);
304
- try {
305
- await pushes.done;
306
- }
307
- finally {
308
- holding.delete(handOn);
309
- pushes.stop();
310
- pushes.run("rekey", flush);
311
- await pushes.drained();
312
- }
313
- };
314
- const ownLogout = (state) => state === "logged_out" && loggingOut;
315
60
  // A deletion for the account alone, a star and a chat marked read are changes to its synced settings, not messages.
316
61
  const synced = settingsWrite(translated, words);
317
- // The phone's new upload replaces the whole stored ref on `file`, so cli-core saves it and a later download or a
318
- // forward uses the live path; a second read of the same file in this run uses it too.
319
- async function* fetched(file, attachment, key) {
320
- const folder = await mkdtemp(join(tmpdir(), "wa-media-"));
321
- try {
322
- const path = join(folder, "file");
323
- const renewed = await translated(engine.download(attachment, path, key));
324
- if (renewed) {
325
- // The old URL names the dead copy; without one, the engine builds it from the new path.
326
- const { url: _dead, ...kept } = attachment.ref;
327
- attachment.ref = { ...kept, ...renewed };
328
- file.providerRef = attachment.ref;
329
- }
330
- yield* createReadStream(path);
331
- }
332
- finally {
333
- await rm(folder, { recursive: true, force: true });
334
- }
335
- }
336
62
  const core = {
63
+ ...discoveryAdapter(engine, translated),
64
+ ...commentAdapter(engine, stored, translated),
65
+ ...sendAdapter({ engine, stored, held, keyOf, translated, resolve: (chat) => core.resolve(chat) }),
66
+ ...listenAdapter({ engine, stored, words, warn, translated }),
337
67
  formatMarkdown: async (text) => parseMarkdownSpans(text),
338
68
  self: () => engine.self(),
339
69
  newSendId: () => `3EB0${randomBytes(9).toString("hex").toUpperCase()}`,
@@ -370,99 +100,13 @@ warn = (line) => process.stderr.write(`${line}\n`)) => {
370
100
  const card = await translated(engine.chat(jid));
371
101
  return { ...toChat(card.chat), members: card.members?.map(toMember) ?? null };
372
102
  },
373
- send: async (chatId, text, options) => {
374
- const refused = REFUSED.filter((option) => options[option] !== undefined && options[option] !== false);
375
- if (refused.length > 0)
376
- throw new CliError("validation_error", `WhatsApp cannot send with ${refused.join(", ")}`);
377
- const uploads = options.attachments ?? [];
378
- const [upload] = uploads;
379
- const files = uploads.map((one) => ({ upload: one, kind: uploadKind(one) }));
380
- if (files.length > 1 && files.some(({ kind }) => kind !== "photo" && kind !== "video"))
381
- throw new CliError("validation_error", "a WhatsApp album holds photos and videos only; send a document in a message of its own");
382
- if (upload?.kind === "voice" && text.trim() !== "")
383
- throw new CliError("validation_error", "WhatsApp sends a voice message alone, without text");
384
- const { stickerFile } = options;
385
- // Untried on a live channel, where a post carries a URL and no key.
386
- if (stickerFile && isChannel(chatId))
387
- throw new CliError("validation_error", "wa does not post stickers to a WhatsApp channel yet");
388
- if (isChannel(chatId))
389
- await refuseChannelPost(engine, translated, chatId, {
390
- files: files.length,
391
- reply: options.replyTo !== undefined,
392
- });
393
- // A reply to a message the store lacks still goes, with its id alone.
394
- const answered = options.replyTo === undefined ? undefined : await stored.message(chatId, options.replyTo);
395
- const request = {
396
- chat: chatId,
397
- text: options.formatting ? toWhatsApp(text, options.formatting) : text,
398
- id: options.sendId,
399
- ...(options.replyTo === undefined ? {} : { replyTo: options.replyTo }),
400
- ...(answered
401
- ? { quoted: { fromMe: answered.outgoing === true, sender: answered.senderId, text: answered.text } }
402
- : {}),
403
- };
404
- const message = await translated(files.length > 1
405
- ? allOnDisk(files, (placed) => engine.sendAlbum({
406
- ...request,
407
- files: placed.map(({ upload, kind, path }) => ({ kind, path, name: upload.name })),
408
- }))
409
- : upload
410
- ? onDisk(upload.source, (path) => engine.sendMedia({ ...request, file: { kind: uploadKind(upload), path, name: upload.name } }))
411
- : stickerFile
412
- ? onDisk(stickerFile.source, (path) => engine.sendMedia({ ...request, file: { kind: "sticker", path, name: stickerFile.name } }))
413
- : engine.send(request));
414
- return { message: toMessage(message), sendId: options.sendId };
415
- },
416
- forward: async (fromChatId, messageId, toChatId, { sendId, threadId }) => {
417
- if (threadId !== undefined)
418
- throw new CliError("validation_error", "WhatsApp forwards without a topic");
419
- const original = await held(fromChatId, messageId);
420
- const attachments = original.attachments.map(toWaAttachment);
421
- const [attachment] = attachments;
422
- if (attachments.length > 1 || (attachments.length === 1 && !attachment?.ref))
423
- throw new CliError("validation_error", "the local store does not hold this message's content whole, so it cannot be forwarded; forward it on the phone");
424
- if (!attachment && original.text === "")
425
- throw new CliError("validation_error", "that message has nothing to forward");
426
- // A channel's files carry no key, and a chat's recipient cannot open a file without one.
427
- if (isChannel(fromChatId) && attachment?.ref && "directPath" in attachment.ref)
428
- throw new CliError("validation_error", "a WhatsApp channel's file is not encrypted, so a forward would arrive as a file that does not open; " +
429
- "save it on the phone and send it as a new file", { reason: "media.channel-file-forward" });
430
- const copy = await translated(engine.forward({ chat: toChatId, id: sendId, text: original.text, attachment: attachment ?? null }));
431
- return { ...toMessage(copy), forwardedFrom: quoted(original) };
432
- },
433
- // From the keys in the store: WhatsApp has no request for an old message. A file that is not one (a place, a
434
- // contact card) or that the store has no keys for is named in `skipped`. The message's key goes along, so the
435
- // engine can ask the phone for a file WhatsApp no longer keeps.
436
- download: async (chat, messageId) => {
437
- const { id: chatId } = await core.resolve(chat);
438
- const message = await held(chatId, messageId);
439
- const files = [];
440
- const skipped = [];
441
- message.attachments.forEach((stored, position) => {
442
- const attachment = toWaAttachment(stored);
443
- if (!isFile(attachment)) {
444
- skipped.push(stored.kind);
445
- return;
446
- }
447
- const file = {
448
- kind: attachment.kind,
449
- ...(attachment.name === undefined ? {} : { name: attachment.name }),
450
- ...(attachment.mime === undefined ? {} : { mime: attachment.mime }),
451
- ...(attachment.size === undefined ? {} : { size: attachment.size }),
452
- position,
453
- bytes: () => fetched(file, attachment, keyOf(chatId, message)),
454
- };
455
- files.push(file);
456
- });
457
- return { files, skipped };
458
- },
459
103
  edit: async (chatId, messageId, text, { markup, formatting }) => {
460
104
  if (markup !== undefined)
461
105
  throw new CliError("validation_error", "WhatsApp cannot edit with markup");
462
106
  const original = await held(chatId, messageId);
463
107
  if (!original.outgoing)
464
108
  throw new CliError("validation_error", "only your own messages can be edited");
465
- if (Date.now() - Date.parse(original.timestamp) > EDIT_WINDOW_MS)
109
+ if (now() - Date.parse(original.timestamp) > EDIT_WINDOW_MS)
466
110
  throw new CliError("validation_error", "WhatsApp takes an edit only within 20 minutes of sending");
467
111
  const edited = await translated(engine.edit(keyOf(chatId, original), formatting ? toWhatsApp(text, formatting) : text));
468
112
  return { ...original, text: edited.text, editedAt: edited.editedAt };
@@ -524,37 +168,7 @@ warn = (line) => process.stderr.write(`${line}\n`)) => {
524
168
  const items = older.messages.map(toMessage).sort((one, other) => one.timestamp.localeCompare(other.timestamp));
525
169
  return { items, hasMore: older.more };
526
170
  },
527
- logout: () => {
528
- loggingOut = true;
529
- return translated(engine.logout());
530
- },
531
171
  close: () => engine.close(),
532
- watch: async (onEvent, signal, onReady) => {
533
- const live = handingOn(onEvent, signal);
534
- try {
535
- await translated(engine.ready(signal));
536
- if (!signal.aborted)
537
- onReady?.();
538
- await live.done;
539
- }
540
- finally {
541
- await live.stop();
542
- }
543
- },
544
- // What a one-shot connection receives: WhatsApp hands a queued message to a linked device once, so it is saved
545
- // here or lost. Never connects; the command's own request does that.
546
- listen: async (onEvent, signal) => {
547
- const live = handingOn(onEvent, signal, ownLogout);
548
- try {
549
- await live.done;
550
- }
551
- finally {
552
- await live.stop();
553
- }
554
- },
555
- // The history a one-shot connection is pushed, which WhatsApp likewise sends once. Never connects.
556
- listenHistory: (onBatch, signal) => pushing(onBatch, signal, ownLogout),
557
- feed: (onBatch, signal) => pushing(onBatch, signal),
558
172
  // Only phone-form one-to-one chats can need a re-key, and in one request: cli-core waits at most 5 s for it.
559
173
  storedChats: async (ids, recipients = []) => {
560
174
  const phones = ids.filter((id) => PHONE_CHAT.test(id));
@@ -563,7 +177,7 @@ warn = (line) => process.stderr.write(`${line}\n`)) => {
563
177
  await translated(engine.storedChats(phones, typed.length > 0 ? typed : undefined));
564
178
  },
565
179
  };
566
- const polls = pollAdapter({ engine, stored, held, keyOf, translated });
180
+ const polls = pollAdapter({ engine, stored, held, keyOf, translated, now });
567
181
  const groups = groupAdapter({
568
182
  engine,
569
183
  resolve: core.resolve,