whatsapp_notifier 0.8.1 → 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/history.test.ts +68 -1
- data/lib/whatsapp_notifier/services/web_automation/history.ts +34 -7
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +120 -6
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +115 -16
- 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
|
|
@@ -20,7 +20,14 @@ import {
|
|
|
20
20
|
type HistoryDeps,
|
|
21
21
|
type RefetchDeps
|
|
22
22
|
} from './history';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
configureInbound,
|
|
25
|
+
loadTargets,
|
|
26
|
+
resetInboundState,
|
|
27
|
+
rememberLidAlias,
|
|
28
|
+
resolveLidAlias,
|
|
29
|
+
type ChatLike
|
|
30
|
+
} from './inbound';
|
|
24
31
|
import {
|
|
25
32
|
configureMedia,
|
|
26
33
|
resetMediaState,
|
|
@@ -231,6 +238,44 @@ test('replayHistory returns oldest-first even when the chat yields newest-first'
|
|
|
231
238
|
expect(history.map((m) => m.messageId)).toEqual(['m1', 'm2', 'm3']);
|
|
232
239
|
});
|
|
233
240
|
|
|
241
|
+
// fromMe items in an @lid-keyed chat: the host requested this history by the
|
|
242
|
+
// phone @c.us, and fetchMessages only returns THIS chat's messages — so the
|
|
243
|
+
// requested id IS the @lid's phone. Same prod chat shape as the live-capture
|
|
244
|
+
// bug (125417440686124@lid): without resolution these were unmatchable.
|
|
245
|
+
test('replayHistory resolves fromMe @lid counterparties to the requested chat id and learns the alias', async () => {
|
|
246
|
+
const LID = '125417440686124@lid';
|
|
247
|
+
const history = await replayHistory('h1', chatWith([
|
|
248
|
+
msg({ body: 'customer says hi', timestamp: 1 }),
|
|
249
|
+
msg({
|
|
250
|
+
fromMe: true, from: OPERATOR, to: LID, body: 'Yes cool',
|
|
251
|
+
id: { _serialized: 'op1' }, timestamp: 2
|
|
252
|
+
})
|
|
253
|
+
]), 50, CUST);
|
|
254
|
+
|
|
255
|
+
expect(history.length).toBe(2);
|
|
256
|
+
expect(history[0].from).toBe(CUST); // inbound leg untouched
|
|
257
|
+
expect(history[1]).toMatchObject({ fromMe: true, to: CUST, body: 'Yes cool' });
|
|
258
|
+
expect(resolveLidAlias('h1', LID)).toBe(CUST); // learned → live fromMe capture resolves too
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('replayHistory falls back to the learned alias map and skips unresolvable fromMe @lid items', async () => {
|
|
262
|
+
const LID = '125417440686124@lid';
|
|
263
|
+
|
|
264
|
+
// No requested chat id (test seam) but a learned alias → resolved.
|
|
265
|
+
rememberLidAlias('h2', LID, CUST);
|
|
266
|
+
const withAlias = await replayHistory('h2', chatWith([
|
|
267
|
+
msg({ fromMe: true, from: OPERATOR, to: LID, id: { _serialized: 'op1' } })
|
|
268
|
+
]), 50);
|
|
269
|
+
expect(withAlias.length).toBe(1);
|
|
270
|
+
expect(withAlias[0].to).toBe(CUST);
|
|
271
|
+
|
|
272
|
+
// Neither → skipped: never forward an unmatchable counterparty.
|
|
273
|
+
const unresolved = await replayHistory('h3', chatWith([
|
|
274
|
+
msg({ fromMe: true, from: OPERATOR, to: LID, id: { _serialized: 'op2' } })
|
|
275
|
+
]), 50);
|
|
276
|
+
expect(unresolved).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
|
|
234
279
|
test('replayHistory passes the limit through and tolerates a non-array result', async () => {
|
|
235
280
|
let seenLimit = 0;
|
|
236
281
|
await replayHistory('1', chatWith([], ({ limit }) => { seenLimit = limit; }), 37);
|
|
@@ -398,6 +443,28 @@ test('historyResponse replays the chat, clamps the limit and allowlists the chat
|
|
|
398
443
|
expect(data.lastUsed).toBeGreaterThan(0);
|
|
399
444
|
});
|
|
400
445
|
|
|
446
|
+
// End-to-end shape of the prod incident: the host asks for a phone whose chat
|
|
447
|
+
// is @lid-keyed (getChatById fails → index.ts's contact fallback resolves it),
|
|
448
|
+
// and the replayed fromMe items must come back threaded on that phone.
|
|
449
|
+
test('historyResponse resolves fromMe @lid items in an @lid-keyed chat to the requested phone', async () => {
|
|
450
|
+
const LID = '125417440686124@lid';
|
|
451
|
+
const deps = depsWith(readyClient(), {
|
|
452
|
+
resolveChat: async () => chatWith([
|
|
453
|
+
msg({ fromMe: true, from: OPERATOR, to: LID, body: 'No still not', id: { _serialized: 'op1' } })
|
|
454
|
+
])
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const res = await historyResponse('h9', { chatId: CUST }, undefined, undefined, deps);
|
|
458
|
+
|
|
459
|
+
expect(res.status).toBe(200);
|
|
460
|
+
const payload = await res.json();
|
|
461
|
+
expect(payload.messages).toEqual([{
|
|
462
|
+
from: OPERATOR, to: CUST, fromMe: true, body: 'No still not',
|
|
463
|
+
messageId: 'op1', timestamp: 1717000000, type: 'chat'
|
|
464
|
+
}]);
|
|
465
|
+
expect(resolveLidAlias('h9', LID)).toBe(CUST);
|
|
466
|
+
});
|
|
467
|
+
|
|
401
468
|
test('historyResponse defaults the limit to 50 when the body omits it', async () => {
|
|
402
469
|
let seenLimit = 0;
|
|
403
470
|
const data = readyClient({
|
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
InboundMediaInfo,
|
|
14
14
|
ChatLike,
|
|
15
15
|
shouldCapture,
|
|
16
|
-
normalizeInbound
|
|
16
|
+
normalizeInbound,
|
|
17
|
+
resolveLidAlias,
|
|
18
|
+
rememberLidAlias
|
|
17
19
|
} from './inbound';
|
|
18
20
|
import { verifyMediaToken, MediaResolution, sanitizeId } from './media';
|
|
19
21
|
|
|
@@ -96,12 +98,37 @@ export function historyMediaInfo(): InboundMediaInfo {
|
|
|
96
98
|
// host threads whole conversations. Messages failing shouldCapture (system
|
|
97
99
|
// events, status, group posts) are skipped. Returned oldest-first so the
|
|
98
100
|
// host can ingest in thread order.
|
|
99
|
-
|
|
101
|
+
//
|
|
102
|
+
// fromMe items in an @lid-keyed chat carry the @lid at `to` — unmatchable
|
|
103
|
+
// host-side, exactly the live-capture bug. requestedChatId is the @c.us id
|
|
104
|
+
// the host asked POST /history for (normalizeHistoryChatId enforces the
|
|
105
|
+
// suffix), and fetchMessages only ever returns THIS chat's messages, so that
|
|
106
|
+
// id IS the @lid's phone: resolve with it and learn the alias — which is also
|
|
107
|
+
// how a restarted service can relearn mappings (and backfill previously
|
|
108
|
+
// dropped operator messages) without waiting for the customer to write.
|
|
109
|
+
// Without it (test seam) the learned map is the fallback; what neither
|
|
110
|
+
// resolves is skipped — the same never-forward-an-unmatchable-counterparty
|
|
111
|
+
// rule as the live fromMe leg.
|
|
112
|
+
export async function replayHistory(
|
|
113
|
+
userId: string,
|
|
114
|
+
chat: ChatLike,
|
|
115
|
+
limit: number,
|
|
116
|
+
requestedChatId?: string
|
|
117
|
+
): Promise<InboundMsg[]> {
|
|
100
118
|
const msgs = await chat.fetchMessages({ limit });
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
const out: InboundMsg[] = [];
|
|
120
|
+
for (const m of (Array.isArray(msgs) ? msgs : [])) {
|
|
121
|
+
if (!shouldCapture(userId, m)) continue;
|
|
122
|
+
const inbound = normalizeInbound(m, m.hasMedia ? historyMediaInfo() : undefined);
|
|
123
|
+
if (inbound.fromMe && inbound.to && inbound.to.endsWith('@lid')) {
|
|
124
|
+
const resolved = requestedChatId || resolveLidAlias(userId, inbound.to);
|
|
125
|
+
if (!resolved) continue;
|
|
126
|
+
if (requestedChatId) rememberLidAlias(userId, inbound.to, requestedChatId);
|
|
127
|
+
inbound.to = resolved;
|
|
128
|
+
}
|
|
129
|
+
out.push(inbound);
|
|
130
|
+
}
|
|
131
|
+
return out.sort((a, b) => a.timestamp - b.timestamp);
|
|
105
132
|
}
|
|
106
133
|
|
|
107
134
|
// ── Route responses ──
|
|
@@ -193,7 +220,7 @@ export async function historyResponse(
|
|
|
193
220
|
const chat = await deps.resolveChat(gate.client, chatId);
|
|
194
221
|
if (!chat) return deny(404, 'chat not found');
|
|
195
222
|
|
|
196
|
-
const messages = await replayHistory(userId, chat, clampHistoryLimit(body && body.limit));
|
|
223
|
+
const messages = await replayHistory(userId, chat, clampHistoryLimit(body && body.limit), chatId);
|
|
197
224
|
|
|
198
225
|
// A synced chat is a conversation of record: allowlist it like a /send
|
|
199
226
|
// recipient so disconnect-window replies to it backfill on reconnect.
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
loadTargets,
|
|
11
11
|
rememberTarget,
|
|
12
12
|
rememberLidAlias,
|
|
13
|
+
resolveLidAlias,
|
|
13
14
|
rememberSelfSend,
|
|
14
15
|
isSelfSend,
|
|
15
16
|
resolveChat,
|
|
@@ -158,13 +159,17 @@ test('rememberLidAlias stores the @lid chat id for a known target', () => {
|
|
|
158
159
|
expect(loadTargets('la1').has(CUST)).toBe(true);
|
|
159
160
|
});
|
|
160
161
|
|
|
161
|
-
test('rememberLidAlias
|
|
162
|
+
test('rememberLidAlias keeps unknown senders off the allowlist and skips non-@lid raw ids', () => {
|
|
162
163
|
rememberTarget('la2', CUST);
|
|
163
164
|
|
|
164
165
|
rememberLidAlias('la2', '999@lid', '918888000000@c.us'); // resolved not a target
|
|
165
166
|
rememberLidAlias('la2', CUST, CUST); // raw is already @c.us
|
|
166
167
|
|
|
167
168
|
expect(loadTargets('la2')).toEqual(new Set([CUST]));
|
|
169
|
+
// The mapping itself IS learned for the unknown sender (the fromMe leg
|
|
170
|
+
// needs it regardless of the allowlist), but never for a non-@lid raw id.
|
|
171
|
+
expect(resolveLidAlias('la2', '999@lid')).toBe('918888000000@c.us');
|
|
172
|
+
expect(resolveLidAlias('la2', CUST)).toBeUndefined();
|
|
168
173
|
});
|
|
169
174
|
|
|
170
175
|
// backfill resolves chats directly, then via contact, and skips dead targets
|
|
@@ -414,11 +419,11 @@ test('processInbound resolves media for operator-sent media messages', async ()
|
|
|
414
419
|
expect(drained[0].mediaSize).toBe(10);
|
|
415
420
|
});
|
|
416
421
|
|
|
417
|
-
// An @lid counterparty on fromMe
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// as the inbound @lid drop.
|
|
421
|
-
test('processInbound drops a fromMe message to an @lid chat before any download', async () => {
|
|
422
|
+
// An @lid counterparty on fromMe that resolves through NEITHER the learned
|
|
423
|
+
// alias map NOR the live contact lookup (here: a message with no client
|
|
424
|
+
// handle at all) stays dropped with a log, before any download — same
|
|
425
|
+
// disk-hygiene rule as the inbound @lid drop.
|
|
426
|
+
test('processInbound drops a fromMe message to an unresolvable @lid chat before any download', async () => {
|
|
422
427
|
let resolveCalls = 0;
|
|
423
428
|
const m = mediaMsg({ fromMe: true, from: OPERATOR, to: LID_FROM });
|
|
424
429
|
|
|
@@ -431,6 +436,115 @@ test('processInbound drops a fromMe message to an @lid chat before any download'
|
|
|
431
436
|
expect(loadTargets('fm4').size).toBe(0); // an unmatchable chat earns no allowlist slot
|
|
432
437
|
});
|
|
433
438
|
|
|
439
|
+
// ── fromMe @lid resolution (the two-way rollout blocker) ──
|
|
440
|
+
//
|
|
441
|
+
// Prod evidence 2026-07-10, chat 125417440686124@lid: customer messages in
|
|
442
|
+
// the chat resolved to the phone and flowed, while EVERY operator-phone
|
|
443
|
+
// message hit the unconditional drop. Resolution order: learned alias map →
|
|
444
|
+
// one live getContactById(<@lid>) lookup (the same call the inbound leg is
|
|
445
|
+
// proven on) → logged drop.
|
|
446
|
+
|
|
447
|
+
test('processInbound resolves a fromMe @lid counterparty from the learned alias map', async () => {
|
|
448
|
+
// The customer wrote earlier — the inbound leg learned the alias.
|
|
449
|
+
rememberLidAlias('fl1', LID_FROM, CUST);
|
|
450
|
+
|
|
451
|
+
let contactLookups = 0;
|
|
452
|
+
const m = msg({
|
|
453
|
+
fromMe: true, from: OPERATOR, to: LID_FROM, body: 'Yes cool',
|
|
454
|
+
client: { getContactById: async () => { contactLookups += 1; return { number: '919999000001' }; } }
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const pushed: InboundMsg[] = [];
|
|
458
|
+
await processInbound('fl1', m, {
|
|
459
|
+
resolveMedia: async () => ({ mediaStatus: 'available' as const }),
|
|
460
|
+
push: (_u, inbound) => pushed.push(inbound)
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const drained = drainInbound('fl1');
|
|
464
|
+
expect(drained.length).toBe(1);
|
|
465
|
+
expect(drained[0].fromMe).toBe(true);
|
|
466
|
+
expect(drained[0].to).toBe(CUST); // resolved phone, not the @lid
|
|
467
|
+
expect(drained[0].body).toBe('Yes cool');
|
|
468
|
+
expect(pushed).toEqual(drained); // webhook saw the same payload
|
|
469
|
+
expect(contactLookups).toBe(0); // map hit → no puppeteer roundtrip
|
|
470
|
+
expect(loadTargets('fl1').has(CUST)).toBe(true); // allowlisted like any fromMe counterparty
|
|
471
|
+
expect(loadTargets('fl1').has(LID_FROM)).toBe(true); // the chat's REAL key joins the allowlist too
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('processInbound live-resolves a fromMe @lid chat and learns the alias for next time', async () => {
|
|
475
|
+
const seenIds: string[] = [];
|
|
476
|
+
const m = msg({
|
|
477
|
+
fromMe: true, from: OPERATOR, to: LID_FROM, body: 'No still not',
|
|
478
|
+
client: { getContactById: async (id: string) => { seenIds.push(id); return { number: '919999000001' }; } }
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await processInbound('fl2', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
|
|
482
|
+
|
|
483
|
+
const drained = drainInbound('fl2');
|
|
484
|
+
expect(drained.length).toBe(1);
|
|
485
|
+
expect(drained[0].to).toBe(CUST);
|
|
486
|
+
expect(seenIds).toEqual([LID_FROM]); // the inbound leg's exact lookup, by the @lid
|
|
487
|
+
expect(resolveLidAlias('fl2', LID_FROM)).toBe(CUST); // learned → the next message is a map hit
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('live resolution trusts contact.id.user only when the contact id is phone-keyed', async () => {
|
|
491
|
+
// No `number`, but a @c.us-keyed contact id carries the phone at id.user.
|
|
492
|
+
const good = msg({
|
|
493
|
+
fromMe: true, from: OPERATOR, to: LID_FROM,
|
|
494
|
+
client: { getContactById: async () => ({ id: { user: '919999000001', _serialized: CUST } }) }
|
|
495
|
+
});
|
|
496
|
+
await processInbound('fl3', good, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
|
|
497
|
+
expect(drainInbound('fl3')[0].to).toBe(CUST);
|
|
498
|
+
|
|
499
|
+
// An @lid-keyed contact id must NOT mint a bogus "phone" out of the
|
|
500
|
+
// privacy id's own digits — that message is unresolvable and dropped.
|
|
501
|
+
const bogus = msg({
|
|
502
|
+
fromMe: true, from: OPERATOR, to: LID_FROM, id: { _serialized: 'b1' },
|
|
503
|
+
client: { getContactById: async () => ({ id: { user: '125417440686124', _serialized: LID_FROM } }) }
|
|
504
|
+
});
|
|
505
|
+
await processInbound('fl4', bogus, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
|
|
506
|
+
expect(drainInbound('fl4')).toEqual([]);
|
|
507
|
+
expect(resolveLidAlias('fl4', LID_FROM)).toBeUndefined();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('processInbound still drops a fromMe @lid message when the live lookup throws', async () => {
|
|
511
|
+
let resolveCalls = 0;
|
|
512
|
+
const m = mediaMsg({
|
|
513
|
+
fromMe: true, from: OPERATOR, to: LID_FROM,
|
|
514
|
+
client: { getContactById: async () => { throw new Error('contact store not hydrated'); } }
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await processInbound('fl5', m, {
|
|
518
|
+
resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; }
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(resolveCalls).toBe(0); // dropped BEFORE any download
|
|
522
|
+
expect(drainInbound('fl5')).toEqual([]);
|
|
523
|
+
expect(loadTargets('fl5').size).toBe(0);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('rememberLidAlias persists the @lid → phone mapping across a restart', () => {
|
|
527
|
+
rememberLidAlias('al1', LID_FROM, CUST);
|
|
528
|
+
expect(resolveLidAlias('al1', LID_FROM)).toBe(CUST);
|
|
529
|
+
|
|
530
|
+
const file = join(dirFor('al1'), 'lid_aliases.json');
|
|
531
|
+
expect(existsSync(file)).toBe(true);
|
|
532
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ [LID_FROM]: CUST });
|
|
533
|
+
|
|
534
|
+
resetInboundState(); // the restart: drop in-memory cache → must reload from disk
|
|
535
|
+
configureInbound(dirFor);
|
|
536
|
+
expect(resolveLidAlias('al1', LID_FROM)).toBe(CUST);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('clearInbound drops the cached alias map so a re-pair cannot inherit old mappings', () => {
|
|
540
|
+
rememberLidAlias('al2', LID_FROM, CUST);
|
|
541
|
+
// Logout wipes the session dir (incl. lid_aliases.json) from disk...
|
|
542
|
+
rmSync(dirFor('al2'), { recursive: true, force: true });
|
|
543
|
+
// ...but without clearInbound the in-memory cache would still resolve.
|
|
544
|
+
clearInbound('al2');
|
|
545
|
+
expect(resolveLidAlias('al2', LID_FROM)).toBeUndefined();
|
|
546
|
+
});
|
|
547
|
+
|
|
434
548
|
// ── Self-send echo suppression ──
|
|
435
549
|
//
|
|
436
550
|
// Every /send fires its own fromMe message_create echo. A registry hit must
|