whatsapp_notifier 0.8.2 → 0.9.1
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.
- checksums.yaml +4 -4
- data/README.md +391 -102
- data/docs/CONFIGURATION.md +89 -0
- data/lib/whatsapp_notifier/client.rb +4 -0
- data/lib/whatsapp_notifier/error_code.rb +137 -0
- data/lib/whatsapp_notifier/errors.rb +21 -0
- data/lib/whatsapp_notifier/providers/base.rb +18 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +14 -3
- data/lib/whatsapp_notifier/services/web_automation/index.ts +59 -0
- data/lib/whatsapp_notifier/services/web_automation/metrics.test.ts +3 -0
- data/lib/whatsapp_notifier/services/web_automation/metrics.ts +3 -0
- data/lib/whatsapp_notifier/services/web_automation/sessions.test.ts +22 -0
- data/lib/whatsapp_notifier/services/web_automation/sessions.ts +19 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +6 -2
- data/lib/whatsapp_notifier.rb +11 -0
- data/spec/error_code_spec.rb +107 -0
- data/spec/providers/web_automation_spec.rb +62 -0
- data/spec/session_ready_spec.rb +114 -0
- data/spec/web_adapter_spec.rb +35 -0
- metadata +5 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Configuration reference
|
|
2
|
+
|
|
3
|
+
Every configuration option and every environment variable, with its type and
|
|
4
|
+
default read from the source. The gem is configured in Ruby; the bundled service
|
|
5
|
+
is configured through environment variables.
|
|
6
|
+
|
|
7
|
+
## Ruby configuration
|
|
8
|
+
|
|
9
|
+
Set these in `config/initializers/whatsapp_notifier.rb`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
WhatsAppNotifier.configure do |config|
|
|
13
|
+
config.bulk_max_recipients = 300
|
|
14
|
+
# ...
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`WhatsAppNotifier.configure` runs `validate!` after the block, so an invalid
|
|
19
|
+
value raises `WhatsAppNotifier::ConfigurationError` at boot.
|
|
20
|
+
|
|
21
|
+
| Option | Type | Default | Description |
|
|
22
|
+
| --- | --- | --- | --- |
|
|
23
|
+
| `provider` | Symbol | `:web_automation` | Messaging backend. Only `:web_automation` is supported; any other value fails `validate!`. |
|
|
24
|
+
| `web_adapter` | object | `WhatsAppNotifier::WebAdapter.new` | HTTP client that talks to the service. Must respond to `send_message`, `fetch_qr_code`, and `connection_status`. |
|
|
25
|
+
| `web_session_path` | String | `"tmp/whatsapp_notifier/session.json"` | File where the gem stores per-user session pointers. |
|
|
26
|
+
| `bulk_base_delay_seconds` | Float | `1.0` | Base pause between consecutive bulk sends. |
|
|
27
|
+
| `bulk_jitter_seconds` | Float | `0.3` | Upper bound of random jitter added to each bulk pause. |
|
|
28
|
+
| `bulk_max_recipients` | Integer | `500` | Largest batch `deliver_bulk` accepts. Must be positive. |
|
|
29
|
+
| `bulk_max_attempts` | Integer | `3` | Attempts per message during bulk retries. Must be positive. |
|
|
30
|
+
| `bulk_retryable_error_codes` | Array\<Symbol\> | `%i[rate_limited network_error temporary_failure]` | Error codes that make a failed bulk send eligible for retry. |
|
|
31
|
+
| `logger` | Logger | `Logger.new($stdout)` | Logger used for warnings and diagnostics. |
|
|
32
|
+
| `web_automation_enabled` | Boolean | `true` | When false, the provider raises instead of sending. A kill switch. |
|
|
33
|
+
| `warn_on_risky_provider` | Boolean | `true` | Logs a one-time warning that web automation is unofficial. |
|
|
34
|
+
| `authenticate_with` | callable or nil | `nil` | Runs as a `before_action` inside the engine's controllers (for example `-> { authenticate_user! }`). |
|
|
35
|
+
| `current_user_id_resolver` | callable | resolves `current_user.id` | How the engine identifies the current user when talking to the service. |
|
|
36
|
+
| `parent_controller` | String | `"::ApplicationController"` | Class the engine's controllers inherit from, so they pick up your layout and filters. |
|
|
37
|
+
| `on_inbound_message_handler` | callable or nil | `nil` | Optional host hook invoked with each inbound message hash, for push-based integrations that would rather not poll. |
|
|
38
|
+
|
|
39
|
+
## Ruby-side environment variables
|
|
40
|
+
|
|
41
|
+
Read by the gem (the adapter, doctor, and CLI):
|
|
42
|
+
|
|
43
|
+
| Variable | Default | Description |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `WHATSAPP_NOTIFIER_SERVICE_URL` | `http://127.0.0.1:3001` | Base URL of the service. `https://` URLs are honored natively. |
|
|
46
|
+
| `WHATSAPP_SERVICE_URL` | (unset) | Legacy alias used only when `WHATSAPP_NOTIFIER_SERVICE_URL` is unset. |
|
|
47
|
+
| `WHATSAPP_WEBHOOK_TOKEN` | (unset) | When set, the adapter sends it as the `X-WA-Token` header on media, chats, and history requests, matching the service's gate. |
|
|
48
|
+
| `PUPPETEER_EXECUTABLE_PATH` | (unset) | Path to the Chromium/Chrome binary. Checked by `doctor` and the `service` command. |
|
|
49
|
+
| `WHATSAPP_SESSION_DIR` | `tmp/whatsapp_notifier/.wwebjs_auth` (via `doctor`/CLI) | Session directory the `doctor` check and the `service` launcher use. See the note below. |
|
|
50
|
+
|
|
51
|
+
## Service environment variables
|
|
52
|
+
|
|
53
|
+
Read by the bundled Bun service under
|
|
54
|
+
`lib/whatsapp_notifier/services/web_automation`:
|
|
55
|
+
|
|
56
|
+
| Variable | Default | Description |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `PORT` | `3001` | Port the service listens on. The `service` command's `--port` flag sets this. |
|
|
59
|
+
| `WHATSAPP_SESSION_DIR` | `/whatsapp_data` | Base directory for per-user WhatsApp sessions. See the note below. |
|
|
60
|
+
| `PUPPETEER_EXECUTABLE_PATH` | (Puppeteer default) | Chromium/Chrome binary to launch. |
|
|
61
|
+
| `WHATSAPP_INIT_TIMEOUT_MS` | `90000` | Recycle a client that boots Chromium but never reaches QR or READY. |
|
|
62
|
+
| `WWEBJS_WEB_VERSION` | (library default) | Pin the WhatsApp Web build (for example `2.3000.1023204887`) so a live change on web.whatsapp.com cannot break the client. |
|
|
63
|
+
| `WWEBJS_WEB_VERSION_CACHE_URL` | wa-version template URL | Remote cache location for the pinned web version. |
|
|
64
|
+
| `WHATSAPP_WEBHOOK_URL` | (unset) | If set, the service POSTs each inbound message here instead of relying only on polling. |
|
|
65
|
+
| `WHATSAPP_WEBHOOK_TOKEN` | (unset) | Shared secret sent as `X-WA-Token` on webhook pushes. When set, it also gates `/media`, `/chats`, and `/history`. Set it in production. |
|
|
66
|
+
| `WHATSAPP_MAX_CONCURRENT_INITS` | `3` | Cap on concurrent Chromium launches, so a herd of cold starts cannot exhaust memory. |
|
|
67
|
+
| `WHATSAPP_UNREADY_REAP_MS` | `1800000` (30 min) | Destroy non-ready clients idle at least this long (abandoned pairing screens). Ready clients keep a 72h idle limit. |
|
|
68
|
+
| `WHATSAPP_BROWSER_TIMEOUT_MS` | `60000` | Puppeteer browser launch timeout. |
|
|
69
|
+
| `WHATSAPP_PROTOCOL_TIMEOUT_MS` | `120000` | Puppeteer protocol timeout. |
|
|
70
|
+
| `WHATSAPP_MEDIA_TTL_MS` | `172800000` (48h) | Lifetime of downloaded inbound media before the sweep evicts it. |
|
|
71
|
+
| `WHATSAPP_MEDIA_MAX_BYTES` | `26214400` (25MB) | Per-document download cap. Inline images and voice notes cap at WhatsApp's 16MB ceiling. |
|
|
72
|
+
| `WHATSAPP_MEDIA_MAX_USER_BYTES` | `1073741824` (1GB) | Per-user rolling media cap with LRU eviction. The cap that shapes disk use. |
|
|
73
|
+
| `WHATSAPP_MEDIA_MAX_DISK_BYTES` | `5368709120` (5GB) | Absolute global backstop for total media on disk. |
|
|
74
|
+
|
|
75
|
+
Malformed numeric values (for example `"50GB"`) fall back to the default rather
|
|
76
|
+
than parsing to `NaN` and disabling a limit.
|
|
77
|
+
|
|
78
|
+
### Note on the session directory
|
|
79
|
+
|
|
80
|
+
`WHATSAPP_SESSION_DIR` has two different defaults depending on how the service
|
|
81
|
+
starts:
|
|
82
|
+
|
|
83
|
+
- Run directly (`bun index.ts`), it defaults to `/whatsapp_data`.
|
|
84
|
+
- Run through `bundle exec whatsapp_notifier service`, the CLI sets it, if unset,
|
|
85
|
+
to `tmp/whatsapp_notifier/.wwebjs_auth` relative to the current working
|
|
86
|
+
directory.
|
|
87
|
+
|
|
88
|
+
In production, set `WHATSAPP_SESSION_DIR` explicitly to a durable mount so logins
|
|
89
|
+
survive restarts and redeploys.
|
|
@@ -27,6 +27,10 @@ module WhatsAppNotifier
|
|
|
27
27
|
provider_for(provider || @configuration.provider).connection_status(metadata: metadata)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def session_ready?(metadata: {}, provider: nil)
|
|
31
|
+
provider_for(provider || @configuration.provider).session_ready?(metadata: metadata)
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
def fetch_inbound(metadata: {}, provider: nil)
|
|
31
35
|
provider_for(provider || @configuration.provider).fetch_inbound(metadata: metadata)
|
|
32
36
|
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
module WhatsAppNotifier
|
|
2
|
+
# Stable, machine-readable classification of a failed send.
|
|
3
|
+
#
|
|
4
|
+
# Before 0.9.0 every send failure surfaced as error_code :delivery_exception,
|
|
5
|
+
# so hosts had to fingerprint Result#error_message text to decide whether a
|
|
6
|
+
# retry was safe. These codes replace that. They are part of the public API
|
|
7
|
+
# and will not be renamed:
|
|
8
|
+
#
|
|
9
|
+
# :auth_required session logged out / service refused upfront —
|
|
10
|
+
# nothing was sent
|
|
11
|
+
# :not_on_whatsapp the number has no WhatsApp account
|
|
12
|
+
# :invalid_phone the number is malformed
|
|
13
|
+
# :recipient_unresolved the service could not resolve the recipient id
|
|
14
|
+
# (wedged session) — nothing was sent
|
|
15
|
+
# :service_unreachable the TCP connection never opened — nothing was sent
|
|
16
|
+
# :timeout the request went out but the answer never came, so
|
|
17
|
+
# the outcome is UNKNOWN — a retry may double-send
|
|
18
|
+
# :rate_limited the service throttled the send
|
|
19
|
+
# :delivery_exception unclassified; the pre-0.9.0 catch-all, kept as the
|
|
20
|
+
# fallback so hosts keying on it keep working
|
|
21
|
+
#
|
|
22
|
+
# The first four plus :service_unreachable are "definitely not sent"; only
|
|
23
|
+
# :timeout and :delivery_exception leave the outcome ambiguous.
|
|
24
|
+
module ErrorCode
|
|
25
|
+
UNCLASSIFIED = :delivery_exception
|
|
26
|
+
|
|
27
|
+
ALL = %i[
|
|
28
|
+
auth_required
|
|
29
|
+
not_on_whatsapp
|
|
30
|
+
invalid_phone
|
|
31
|
+
recipient_unresolved
|
|
32
|
+
service_unreachable
|
|
33
|
+
timeout
|
|
34
|
+
rate_limited
|
|
35
|
+
delivery_exception
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# HTTP status → code, for a non-2xx answer from the service. Checked
|
|
39
|
+
# first: a status is exact where the body text is a guess. 422 is
|
|
40
|
+
# deliberately absent — the service 422s a malformed REQUEST (a host bug),
|
|
41
|
+
# not a bad number, so it falls through to the unclassified catch-all.
|
|
42
|
+
STATUS_CODES = {
|
|
43
|
+
401 => :auth_required,
|
|
44
|
+
403 => :auth_required,
|
|
45
|
+
429 => :rate_limited
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Exception class name → code. Matched on the name rather than the
|
|
49
|
+
# constant so this file never has to require net/http or resolve Errno.
|
|
50
|
+
EXCEPTION_CODES = {
|
|
51
|
+
"Errno::ECONNREFUSED" => :service_unreachable,
|
|
52
|
+
"Errno::ECONNRESET" => :service_unreachable,
|
|
53
|
+
"Errno::EHOSTUNREACH" => :service_unreachable,
|
|
54
|
+
"Errno::ENETUNREACH" => :service_unreachable,
|
|
55
|
+
"SocketError" => :service_unreachable,
|
|
56
|
+
"Net::OpenTimeout" => :service_unreachable,
|
|
57
|
+
"Net::ReadTimeout" => :timeout,
|
|
58
|
+
"Net::WriteTimeout" => :timeout,
|
|
59
|
+
"Timeout::Error" => :timeout
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Codes a service (or a third-party adapter) may hand us directly, folded
|
|
63
|
+
# into the vocabulary above. Several spellings map to one code because the
|
|
64
|
+
# Bun service has renamed its own codes across versions.
|
|
65
|
+
SERVICE_CODES = {
|
|
66
|
+
"auth_required" => :auth_required,
|
|
67
|
+
"unauthenticated" => :auth_required,
|
|
68
|
+
"unauthorized" => :auth_required,
|
|
69
|
+
"session_expired" => :auth_required,
|
|
70
|
+
"not_on_whatsapp" => :not_on_whatsapp,
|
|
71
|
+
"number_not_on_whatsapp" => :not_on_whatsapp,
|
|
72
|
+
"number_not_registered" => :not_on_whatsapp,
|
|
73
|
+
"invalid_phone" => :invalid_phone,
|
|
74
|
+
"invalid_number" => :invalid_phone,
|
|
75
|
+
"recipient_unresolved" => :recipient_unresolved,
|
|
76
|
+
"service_unreachable" => :service_unreachable,
|
|
77
|
+
"timeout" => :timeout,
|
|
78
|
+
"rate_limited" => :rate_limited,
|
|
79
|
+
"throttled" => :rate_limited
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
# Free-text fingerprints, the last resort: whatsapp-web.js failures reach
|
|
83
|
+
# us as an opaque 500 whose body is the library's own message, so text is
|
|
84
|
+
# all there is. Order matters — the first match wins.
|
|
85
|
+
MESSAGE_CODES = [
|
|
86
|
+
[/\bno lid\b/i, :recipient_unresolved],
|
|
87
|
+
[/not registered|not on whatsapp|not a valid whatsapp/i, :not_on_whatsapp],
|
|
88
|
+
[/invalid wid|wid error|invalid (phone|number)/i, :invalid_phone],
|
|
89
|
+
[/not authenticated|pair via qr|logged out|session expired|unauthorized/i, :auth_required],
|
|
90
|
+
[/connection refused|failed to open tcp|network is unreachable|no route to host/i, :service_unreachable],
|
|
91
|
+
[/execution expired|timed out|timeout/i, :timeout],
|
|
92
|
+
[/rate limit|too many requests/i, :rate_limited]
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
# Bound on what a service-supplied code may look like before we pass it
|
|
96
|
+
# through unrecognized: an identifier, not arbitrary text.
|
|
97
|
+
CODE_SHAPE = /\A[a-z0-9_]{1,64}\z/
|
|
98
|
+
|
|
99
|
+
class << self
|
|
100
|
+
# Classify the exception a transport raised. ServiceError carries the
|
|
101
|
+
# HTTP status, which beats guessing from the message.
|
|
102
|
+
def from_exception(exception)
|
|
103
|
+
status_code = status_code_for(exception)
|
|
104
|
+
return status_code if status_code
|
|
105
|
+
|
|
106
|
+
EXCEPTION_CODES[exception.class.name] || from_message(exception.message)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Classify a failure the transport reported without raising (a 200 body
|
|
110
|
+
# with success: false).
|
|
111
|
+
def from_message(text)
|
|
112
|
+
haystack = text.to_s
|
|
113
|
+
MESSAGE_CODES.each { |pattern, code| return code if pattern.match?(haystack) }
|
|
114
|
+
UNCLASSIFIED
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Fold a code the service supplied into the vocabulary. Returns nil for
|
|
118
|
+
# a blank code so callers can fall back to their own classification;
|
|
119
|
+
# an unrecognized but identifier-shaped code passes through so a newer
|
|
120
|
+
# service can introduce one without a gem release.
|
|
121
|
+
def normalize(value)
|
|
122
|
+
text = value.to_s.strip.downcase
|
|
123
|
+
return nil if text.empty?
|
|
124
|
+
|
|
125
|
+
SERVICE_CODES[text] || (CODE_SHAPE.match?(text) ? text.to_sym : UNCLASSIFIED)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def status_code_for(exception)
|
|
131
|
+
return nil unless exception.respond_to?(:status)
|
|
132
|
+
|
|
133
|
+
STATUS_CODES[exception.status.to_i]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -2,4 +2,25 @@ module WhatsAppNotifier
|
|
|
2
2
|
class Error < StandardError; end
|
|
3
3
|
class ConfigurationError < Error; end
|
|
4
4
|
class DeliveryError < Error; end
|
|
5
|
+
|
|
6
|
+
# Raised when the service answers a non-2xx. Carries the HTTP status so
|
|
7
|
+
# callers classify on the status instead of re-parsing the message text.
|
|
8
|
+
#
|
|
9
|
+
# Subclasses RuntimeError, NOT WhatsAppNotifier::Error: before 0.9.0 this
|
|
10
|
+
# was a bare `raise "service request failed (...)"`, so hosts wrote
|
|
11
|
+
# `rescue RuntimeError` around media fetches and status polls. Hanging it
|
|
12
|
+
# off Error instead would silently stop those rescues from firing and turn
|
|
13
|
+
# a handled 5xx into an unhandled one.
|
|
14
|
+
#
|
|
15
|
+
# The message keeps its pre-0.9.0 wording ("service request failed (401):
|
|
16
|
+
# ...") for the same reason — hosts that fingerprinted that string keep
|
|
17
|
+
# matching while they migrate to Result#error_code.
|
|
18
|
+
class ServiceError < RuntimeError
|
|
19
|
+
attr_reader :status
|
|
20
|
+
|
|
21
|
+
def initialize(message, status: nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@status = status
|
|
24
|
+
end
|
|
25
|
+
end
|
|
5
26
|
end
|
|
@@ -19,6 +19,24 @@ module WhatsAppNotifier
|
|
|
19
19
|
raise NotImplementedError, "#{self.class.name} does not support status checking"
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# True when the paired session can actually send right now. Wraps
|
|
23
|
+
# #connection_status so hosts stop reimplementing the same
|
|
24
|
+
# "authenticated == true, rescue transport errors to false" check — a
|
|
25
|
+
# status endpoint the host cannot reach means sends can't succeed
|
|
26
|
+
# either, so an unreachable service reads as not-ready.
|
|
27
|
+
#
|
|
28
|
+
# ConfigurationError is deliberately NOT swallowed: that is a boot-time
|
|
29
|
+
# mistake in the host, and answering `false` would quietly park every
|
|
30
|
+
# send behind a "session down" backoff instead of surfacing it.
|
|
31
|
+
def session_ready?(metadata: {})
|
|
32
|
+
status = connection_status(metadata: metadata)
|
|
33
|
+
status.is_a?(Hash) && status[:authenticated] == true
|
|
34
|
+
rescue ConfigurationError
|
|
35
|
+
raise
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
configuration.logger&.warn("[WhatsAppNotifier] session_ready? failed: #{e.message}")
|
|
38
|
+
false
|
|
39
|
+
end
|
|
22
40
|
end
|
|
23
41
|
end
|
|
24
42
|
end
|
|
@@ -17,19 +17,22 @@ module WhatsAppNotifier
|
|
|
17
17
|
response = adapter.send_message(payload: payload, session: session)
|
|
18
18
|
persist_session(response.fetch(:session, {}), payload.fetch(:metadata, {}))
|
|
19
19
|
|
|
20
|
+
success = response.fetch(:success)
|
|
20
21
|
Result.new(
|
|
21
|
-
success:
|
|
22
|
+
success: success,
|
|
22
23
|
provider: :web_automation,
|
|
23
24
|
message_id: response[:message_id],
|
|
24
|
-
error_code: response
|
|
25
|
+
error_code: error_code_for(success, response),
|
|
25
26
|
error_message: response[:error_message],
|
|
26
27
|
wait_seconds: response[:wait_seconds],
|
|
27
28
|
metadata: response.fetch(:metadata, {})
|
|
28
29
|
)
|
|
29
30
|
rescue StandardError => e
|
|
30
|
-
Result.new(success: false, provider: :web_automation,
|
|
31
|
+
Result.new(success: false, provider: :web_automation,
|
|
32
|
+
error_code: ErrorCode.from_exception(e), error_message: e.message)
|
|
31
33
|
end
|
|
32
34
|
|
|
35
|
+
|
|
33
36
|
def scan_qr(metadata: {})
|
|
34
37
|
raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
|
|
35
38
|
|
|
@@ -127,6 +130,14 @@ module WhatsAppNotifier
|
|
|
127
130
|
|
|
128
131
|
private
|
|
129
132
|
|
|
133
|
+
# A code the adapter supplied wins; otherwise classify from the error
|
|
134
|
+
# text, which is all a ≤ 0.8.x service gives us. Successes carry no code.
|
|
135
|
+
def error_code_for(success, response)
|
|
136
|
+
return nil if success
|
|
137
|
+
|
|
138
|
+
response[:error_code] || ErrorCode.from_message(response[:error_message])
|
|
139
|
+
end
|
|
140
|
+
|
|
130
141
|
def session_for(metadata)
|
|
131
142
|
user_id = metadata[:user_id]
|
|
132
143
|
return @store.load unless user_id
|
|
@@ -8,6 +8,7 @@ import { InitGate } from './init_gate';
|
|
|
8
8
|
import {
|
|
9
9
|
hasPairedSession,
|
|
10
10
|
InitRetryLimiter,
|
|
11
|
+
isReadyWedged,
|
|
11
12
|
reapLimitMs,
|
|
12
13
|
touchClient,
|
|
13
14
|
shouldWipeSessionOnReap
|
|
@@ -43,6 +44,13 @@ const BROWSER_EXECUTABLE_PATH = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
|
43
44
|
// WhatsApp Web update breaks the injected store), instead of wedging in
|
|
44
45
|
// INITIALIZING forever with no QR.
|
|
45
46
|
const INIT_TIMEOUT_MS = Number(process.env.WHATSAPP_INIT_TIMEOUT_MS || 90000);
|
|
47
|
+
// Recycle a client that authenticates from the on-disk session but never fires
|
|
48
|
+
// 'ready' (the store never hydrates — RAM pressure, a mid-write SIGKILL, a
|
|
49
|
+
// slow web.whatsapp.com). Without this the client serves qr=null +
|
|
50
|
+
// authenticated=false until the 30-min idle reaper — the "No QR available
|
|
51
|
+
// forever after a deploy" outage. 3 minutes is comfortably above a slow but
|
|
52
|
+
// healthy hydration (~30s observed) and well under the reaper.
|
|
53
|
+
const READY_TIMEOUT_MS = Number(process.env.WHATSAPP_READY_TIMEOUT_MS || 180000);
|
|
46
54
|
// Optionally pin the WhatsApp Web build so a live web.whatsapp.com change can't
|
|
47
55
|
// silently break the client. Set WWEBJS_WEB_VERSION to a known-good version
|
|
48
56
|
// (e.g. "2.3000.1023204887"); leave unset to use the library default.
|
|
@@ -63,6 +71,7 @@ interface ClientData {
|
|
|
63
71
|
// pairing visit whose LocalAuth dir holds no credentials at all.
|
|
64
72
|
everAuthenticated?: boolean;
|
|
65
73
|
initTimer?: ReturnType<typeof setTimeout>;
|
|
74
|
+
readyTimer?: ReturnType<typeof setTimeout>;
|
|
66
75
|
releaseInitSlot?: () => void;
|
|
67
76
|
}
|
|
68
77
|
|
|
@@ -101,6 +110,27 @@ async function pushWebhook(userId: string, msg: InboundMsg) {
|
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
// Session lifecycle push (0.9.1): tell the host the moment a session becomes
|
|
114
|
+
// sendable, so work it parked on "session down" resumes in realtime instead
|
|
115
|
+
// of on its next polling tick. Same URL + token as the message webhook; the
|
|
116
|
+
// payload carries `event` instead of `message`, which pre-0.9.1 hosts reject
|
|
117
|
+
// with a 400 — harmless, and the host's poll-based resume still covers them.
|
|
118
|
+
async function pushSessionEvent(userId: string, event: 'session_ready') {
|
|
119
|
+
if (!WEBHOOK_URL) return;
|
|
120
|
+
try {
|
|
121
|
+
await fetch(WEBHOOK_URL, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
...(WEBHOOK_TOKEN ? { 'X-WA-Token': WEBHOOK_TOKEN } : {})
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify({ userId, event })
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(`Session event push failed for ${userId}`, e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
// Wrapper around the testable pipeline in inbound.ts (sanity filter → sender
|
|
105
135
|
// resolution with early @lid drop → media download → normalize → enqueue →
|
|
106
136
|
// webhook). The catch keeps a single bad message from killing the listener.
|
|
@@ -140,6 +170,13 @@ const initGate = new InitGate(Number(process.env.WHATSAPP_MAX_CONCURRENT_INITS |
|
|
|
140
170
|
const BROWSER_LAUNCH_TIMEOUT_MS = Number(process.env.WHATSAPP_BROWSER_TIMEOUT_MS || 60000);
|
|
141
171
|
const PROTOCOL_TIMEOUT_MS = Number(process.env.WHATSAPP_PROTOCOL_TIMEOUT_MS || 120000);
|
|
142
172
|
|
|
173
|
+
function clearReadyTimer(clientData: ClientData) {
|
|
174
|
+
if (clientData.readyTimer) {
|
|
175
|
+
clearTimeout(clientData.readyTimer);
|
|
176
|
+
clientData.readyTimer = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
143
180
|
function clearInitTimer(clientData: ClientData) {
|
|
144
181
|
if (clientData.initTimer) {
|
|
145
182
|
clearTimeout(clientData.initTimer);
|
|
@@ -290,6 +327,7 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
290
327
|
client.on('qr', async (qr) => {
|
|
291
328
|
clientData.state = 'QR_REQUIRED';
|
|
292
329
|
clearInitTimer(clientData); // progress made — QR is showable
|
|
330
|
+
clearReadyTimer(clientData); // WhatsApp wants a re-scan — a QR IS showing, not a wedge
|
|
293
331
|
try {
|
|
294
332
|
clientData.qr = await toDataURL(qr);
|
|
295
333
|
console.log(`QR RECEIVED and converted for User ${userId}`);
|
|
@@ -303,8 +341,12 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
303
341
|
clientData.qr = null;
|
|
304
342
|
clientData.ready = true;
|
|
305
343
|
clearInitTimer(clientData);
|
|
344
|
+
clearReadyTimer(clientData);
|
|
306
345
|
initRetries.reset(userId);
|
|
307
346
|
console.log(`Client is READY for User ${userId}`);
|
|
347
|
+
// Wake the host NOW — a campaign parked on "session down" should not
|
|
348
|
+
// wait out a polling interval when the session just came back.
|
|
349
|
+
pushSessionEvent(userId, 'session_ready').catch(console.error);
|
|
308
350
|
// Replay anything that arrived while we were disconnected.
|
|
309
351
|
backfillInbound(userId, client).catch((e) => console.error(`Backfill failed for ${userId}`, e));
|
|
310
352
|
});
|
|
@@ -324,12 +366,28 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
324
366
|
clientData.everAuthenticated = true;
|
|
325
367
|
clearInitTimer(clientData);
|
|
326
368
|
console.log(`AUTHENTICATED for User ${userId}`);
|
|
369
|
+
|
|
370
|
+
// Ready watchdog: 'authenticated' just cleared the INITIALIZING
|
|
371
|
+
// watchdog, so from here nothing guards the authenticated→ready hop.
|
|
372
|
+
// If the store never hydrates, recycle — the session dir survives
|
|
373
|
+
// (everAuthenticated), so the next /status poll relaunches Chromium
|
|
374
|
+
// and reconnects without a new QR. Re-armed on every 'authenticated',
|
|
375
|
+
// so a client that re-auths after a hiccup gets a fresh window.
|
|
376
|
+
clearReadyTimer(clientData);
|
|
377
|
+
clientData.readyTimer = setTimeout(() => {
|
|
378
|
+
if (isReadyWedged(clientData)) {
|
|
379
|
+
counters.ready_timeouts_total += 1;
|
|
380
|
+
console.error(`User ${userId} AUTHENTICATED but not ready > ${READY_TIMEOUT_MS}ms — recycling (session kept)`);
|
|
381
|
+
destroyClient(userId).catch(console.error);
|
|
382
|
+
}
|
|
383
|
+
}, READY_TIMEOUT_MS);
|
|
327
384
|
});
|
|
328
385
|
|
|
329
386
|
client.on('auth_failure', (msg) => {
|
|
330
387
|
clientData.state = 'DISCONNECTED';
|
|
331
388
|
clientData.ready = false;
|
|
332
389
|
clearInitTimer(clientData);
|
|
390
|
+
clearReadyTimer(clientData);
|
|
333
391
|
counters.auth_failures_total += 1;
|
|
334
392
|
console.error(`AUTHENTICATION FAILURE for User ${userId}`, msg);
|
|
335
393
|
});
|
|
@@ -409,6 +467,7 @@ async function destroyClient(userId: string, clearSession: boolean = false) {
|
|
|
409
467
|
if (data && !data.isDestroying) {
|
|
410
468
|
data.isDestroying = true;
|
|
411
469
|
clearInitTimer(data);
|
|
470
|
+
clearReadyTimer(data);
|
|
412
471
|
console.log(`Destroying WhatsApp client for User: ${userId} (clearSession: ${clearSession})`);
|
|
413
472
|
try {
|
|
414
473
|
// Unregister listeners to prevent loops or double-destroys
|
|
@@ -14,6 +14,7 @@ describe('newCounters', () => {
|
|
|
14
14
|
init_failures_total: 0,
|
|
15
15
|
ws_endpoint_timeouts_total: 0,
|
|
16
16
|
init_timeouts_total: 0,
|
|
17
|
+
ready_timeouts_total: 0,
|
|
17
18
|
auth_failures_total: 0,
|
|
18
19
|
disconnects_total: 0,
|
|
19
20
|
});
|
|
@@ -57,6 +58,7 @@ describe('renderMetrics', () => {
|
|
|
57
58
|
init_failures_total: 4,
|
|
58
59
|
ws_endpoint_timeouts_total: 2,
|
|
59
60
|
init_timeouts_total: 1,
|
|
61
|
+
ready_timeouts_total: 6,
|
|
60
62
|
auth_failures_total: 3,
|
|
61
63
|
disconnects_total: 5,
|
|
62
64
|
};
|
|
@@ -81,6 +83,7 @@ describe('renderMetrics', () => {
|
|
|
81
83
|
expect(out).toContain('whatsapp_init_failures_total 4');
|
|
82
84
|
expect(out).toContain('whatsapp_ws_endpoint_timeouts_total 2');
|
|
83
85
|
expect(out).toContain('whatsapp_init_timeouts_total 1');
|
|
86
|
+
expect(out).toContain('whatsapp_ready_timeouts_total 6');
|
|
84
87
|
expect(out).toContain('whatsapp_auth_failures_total 3');
|
|
85
88
|
expect(out).toContain('whatsapp_disconnects_total 5');
|
|
86
89
|
expect(out).toContain('whatsapp_service_uptime_seconds 86400');
|
|
@@ -19,6 +19,7 @@ export interface Counters {
|
|
|
19
19
|
init_failures_total: number; // client.initialize() rejected
|
|
20
20
|
ws_endpoint_timeouts_total: number; // Chromium never produced a WS endpoint (crash-loop signature)
|
|
21
21
|
init_timeouts_total: number; // watchdog recycled a stuck-INITIALIZING client
|
|
22
|
+
ready_timeouts_total: number; // watchdog recycled an AUTHENTICATED-but-never-ready wedge
|
|
22
23
|
auth_failures_total: number; // auth_failure event
|
|
23
24
|
disconnects_total: number; // disconnected event
|
|
24
25
|
}
|
|
@@ -28,6 +29,7 @@ export function newCounters(): Counters {
|
|
|
28
29
|
init_failures_total: 0,
|
|
29
30
|
ws_endpoint_timeouts_total: 0,
|
|
30
31
|
init_timeouts_total: 0,
|
|
32
|
+
ready_timeouts_total: 0,
|
|
31
33
|
auth_failures_total: 0,
|
|
32
34
|
disconnects_total: 0,
|
|
33
35
|
};
|
|
@@ -103,6 +105,7 @@ export function renderMetrics(
|
|
|
103
105
|
...counter('whatsapp_init_failures_total', 'client.initialize() rejections.', counters.init_failures_total),
|
|
104
106
|
...counter('whatsapp_ws_endpoint_timeouts_total', 'Chromium launches that never produced a WS endpoint (crash-loop signature).', counters.ws_endpoint_timeouts_total),
|
|
105
107
|
...counter('whatsapp_init_timeouts_total', 'Clients recycled by the INITIALIZING watchdog.', counters.init_timeouts_total),
|
|
108
|
+
...counter('whatsapp_ready_timeouts_total', 'Clients recycled by the ready watchdog (AUTHENTICATED but never ready).', counters.ready_timeouts_total),
|
|
106
109
|
...counter('whatsapp_auth_failures_total', 'auth_failure events.', counters.auth_failures_total),
|
|
107
110
|
...counter('whatsapp_disconnects_total', 'disconnected events.', counters.disconnects_total),
|
|
108
111
|
...gauge('whatsapp_service_uptime_seconds', 'Seconds since the service process started.', uptimeSeconds),
|
|
@@ -5,6 +5,7 @@ import { join } from 'path';
|
|
|
5
5
|
import {
|
|
6
6
|
hasPairedSession,
|
|
7
7
|
InitRetryLimiter,
|
|
8
|
+
isReadyWedged,
|
|
8
9
|
reapLimitMs,
|
|
9
10
|
touchClient,
|
|
10
11
|
shouldWipeSessionOnReap
|
|
@@ -127,3 +128,24 @@ describe('InitRetryLimiter', () => {
|
|
|
127
128
|
expect(limiter.shouldRetry('b')).toBe(false);
|
|
128
129
|
});
|
|
129
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('isReadyWedged', () => {
|
|
133
|
+
test('AUTHENTICATED but never ready is the wedge', () => {
|
|
134
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: false })).toBe(true);
|
|
135
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED' })).toBe(true); // ready never set
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a ready client is the normal path, not a wedge', () => {
|
|
139
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: true })).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a client already being destroyed must not be recycled again', () => {
|
|
143
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: false, isDestroying: true })).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('other states are owned by their own paths', () => {
|
|
147
|
+
expect(isReadyWedged({ state: 'QR_REQUIRED', ready: false })).toBe(false); // QR is showing
|
|
148
|
+
expect(isReadyWedged({ state: 'DISCONNECTED', ready: false })).toBe(false); // disconnect path
|
|
149
|
+
expect(isReadyWedged({ state: 'INITIALIZING', ready: false })).toBe(false); // init watchdog owns it
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -89,3 +89,22 @@ export class InitRetryLimiter {
|
|
|
89
89
|
this.counts.delete(userId);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
// The AUTHENTICATED-but-never-ready wedge (incident 2026-06-05, again
|
|
94
|
+
// 2026-07-31): after a service restart a session re-authenticates from disk,
|
|
95
|
+
// but Chromium never finishes loading the WhatsApp Web store, so `ready`
|
|
96
|
+
// never fires. The client then serves qr=null + authenticated=false forever —
|
|
97
|
+
// the broadcast modal shows "No QR available" and sends 503. The INITIALIZING
|
|
98
|
+
// watchdog can't see it (state already moved on), and the idle reaper takes
|
|
99
|
+
// 30+ minutes. This predicate names the wedge for the ready watchdog that
|
|
100
|
+
// index.ts arms when 'authenticated' fires.
|
|
101
|
+
//
|
|
102
|
+
// NOT wedged: already ready (the normal path), already being destroyed
|
|
103
|
+
// (recycling twice double-frees nothing but spams logs), or moved to another
|
|
104
|
+
// state — QR_REQUIRED means WhatsApp wants a re-scan and IS showing a QR, and
|
|
105
|
+
// DISCONNECTED clients are on the disconnect path already.
|
|
106
|
+
export function isReadyWedged(
|
|
107
|
+
client: { state: string; ready?: boolean; isDestroying?: boolean }
|
|
108
|
+
): boolean {
|
|
109
|
+
return client.state === 'AUTHENTICATED' && !client.ready && !client.isDestroying;
|
|
110
|
+
}
|
|
@@ -67,6 +67,10 @@ module WhatsAppNotifier
|
|
|
67
67
|
message_id: response["messageId"] || response["message_id"] ||
|
|
68
68
|
payload[:idempotency_key] || "local-#{Time.now.to_i}",
|
|
69
69
|
session: session,
|
|
70
|
+
# A code the service supplied wins over anything the provider could
|
|
71
|
+
# infer from the message text. Absent on every service ≤ 0.8.x, which
|
|
72
|
+
# is why the provider still classifies as a fallback.
|
|
73
|
+
error_code: ErrorCode.normalize(response["errorCode"] || response["error_code"]),
|
|
70
74
|
error_message: response["error"]
|
|
71
75
|
}
|
|
72
76
|
end
|
|
@@ -109,7 +113,7 @@ module WhatsAppNotifier
|
|
|
109
113
|
user_id = user_id_from(metadata)
|
|
110
114
|
res = binary_get("/media/#{user_id}/#{path_id(message_id)}")
|
|
111
115
|
return nil if res.code.to_s == "404"
|
|
112
|
-
raise "service request failed (#{res.code}): #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
116
|
+
raise ServiceError.new("service request failed (#{res.code}): #{res.body}", status: res.code) unless res.is_a?(Net::HTTPSuccess)
|
|
113
117
|
|
|
114
118
|
body = res.body.to_s
|
|
115
119
|
{
|
|
@@ -283,7 +287,7 @@ module WhatsAppNotifier
|
|
|
283
287
|
# carries no "success" key, so they degrade rather than raise.
|
|
284
288
|
return parsed if allow_404 && res.code.to_s == "404"
|
|
285
289
|
|
|
286
|
-
raise "service request failed (#{res.code}): #{parsed["error"] || res.body}"
|
|
290
|
+
raise ServiceError.new("service request failed (#{res.code}): #{parsed["error"] || res.body}", status: res.code)
|
|
287
291
|
end
|
|
288
292
|
|
|
289
293
|
def parse_body(raw)
|
data/lib/whatsapp_notifier.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "whatsapp_notifier/version"
|
|
2
2
|
require_relative "whatsapp_notifier/errors"
|
|
3
|
+
require_relative "whatsapp_notifier/error_code"
|
|
3
4
|
require_relative "whatsapp_notifier/result"
|
|
4
5
|
require_relative "whatsapp_notifier/configuration"
|
|
5
6
|
require_relative "whatsapp_notifier/web_adapter"
|
|
@@ -62,6 +63,16 @@ module WhatsAppNotifier
|
|
|
62
63
|
client.connection_status(provider: provider, metadata: metadata)
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
# One call for "can this operator send right now?" — an authenticated
|
|
67
|
+
# session answers true, everything else (including an unreachable status
|
|
68
|
+
# endpoint) answers false. `user_id:` is sugar for the metadata key every
|
|
69
|
+
# multi-user host passes; both forms work.
|
|
70
|
+
def session_ready?(user_id: nil, provider: nil, metadata: {})
|
|
71
|
+
meta = metadata.to_h
|
|
72
|
+
meta = meta.merge(user_id: user_id) unless user_id.nil?
|
|
73
|
+
client.session_ready?(provider: provider, metadata: meta)
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
def fetch_inbound(provider: nil, metadata: {})
|
|
66
77
|
client.fetch_inbound(provider: provider, metadata: metadata)
|
|
67
78
|
end
|