whatsapp_notifier 0.8.2 → 0.9.2

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.
@@ -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: response.fetch(:success),
22
+ success: success,
22
23
  provider: :web_automation,
23
24
  message_id: response[:message_id],
24
- error_code: response[:error_code],
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, error_code: :delivery_exception, error_message: e.message)
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