Class: WhatsAppNotifier::WebAdapter

Inherits:
Object
  • Object
show all
Defined in:
lib/whatsapp_notifier/web_adapter.rb

Constant Summary collapse

DEFAULT_OPEN_TIMEOUT =
5
DEFAULT_READ_TIMEOUT =
30
MEDIA_OPEN_TIMEOUT =

Media bytes can be tens of MB over a slow link — give the binary fetch a longer read window than the JSON control plane.

5
MEDIA_READ_TIMEOUT =
60
HTTP_CLASSES =
{
  post: Net::HTTP::Post,
  get: Net::HTTP::Get,
  delete: Net::HTTP::Delete
}.freeze
INBOUND_OPTIONAL_KEYS =

Optional inbound keys introduced by the 0.7.0 service (media verdict + sender display name) and the 0.8.0 service (two-way capture). Mapped ONLY when the wire payload carries them, so hosts can key-gate on presence: a missing has_media means "0.6.0 service, no media support" (while has_media: false means "text message"), and a missing from_me means "customer message or pre-0.8.0 service". to carries the counterparty chat id on operator-sent (from_me) messages — the id the host threads the conversation on.

{
  has_media: %w[hasMedia has_media],
  media_status: %w[mediaStatus media_status],
  media_error: %w[mediaError media_error],
  media_mime: %w[mediaMime media_mime],
  media_filename: %w[mediaFilename media_filename],
  media_size: %w[mediaSize media_size],
  sender_name: %w[senderName sender_name],
  to: %w[to],
  from_me: %w[fromMe from_me],
  # 0.9.3: the `<digits>@lid` privacy id the counterparty above was
  # RESOLVED FROM, present only for a privacy-keyed chat. The phone in
  # from/to is always a real phone — this says where it came from, so a
  # host can audit a thread's origin. Never a value to thread on.
  sender_lid: %w[senderLid sender_lid]
}.freeze
HISTORY_LIMIT_DEFAULT =

Mirrors the service-side clamp (history.ts) so a host-passed limit can never balloon one request into a session-stalling bulk fetch.

50
HISTORY_LIMIT_RANGE =
(1..200).freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_url: self.class.default_base_url, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT) ⇒ WebAdapter

Returns a new instance of WebAdapter.



49
50
51
52
53
54
55
# File 'lib/whatsapp_notifier/web_adapter.rb', line 49

def initialize(base_url: self.class.default_base_url,
               open_timeout: DEFAULT_OPEN_TIMEOUT,
               read_timeout: DEFAULT_READ_TIMEOUT)
  @base_url = base_url
  @open_timeout = open_timeout
  @read_timeout = read_timeout
end

Class Method Details

.default_base_urlObject



45
46
47
# File 'lib/whatsapp_notifier/web_adapter.rb', line 45

def self.default_base_url
  ENV["WHATSAPP_NOTIFIER_SERVICE_URL"] || ENV["WHATSAPP_SERVICE_URL"] || "http://127.0.0.1:3001"
end

Instance Method Details

#connection_status(metadata: {}) ⇒ Object



89
90
91
92
93
94
95
96
97
# File 'lib/whatsapp_notifier/web_adapter.rb', line 89

def connection_status(metadata: {})
  user_id = user_id_from()
  response = request(:get, "/status/#{user_id}")
  {
    state: response["state"],
    authenticated: response["authenticated"],
    has_qr: response["hasQR"]
  }
end

#delete_media(message_id:, metadata: {}) ⇒ Object

Removes the service's copy after the host has attached the bytes. Idempotent on the service side: deleting absent media still succeeds. A 0.6.0 service mid-rollout has no /media routes and answers 404 — degrade to { success: false } instead of raising, mirroring fetch_media's nil-on-404.



160
161
162
163
164
# File 'lib/whatsapp_notifier/web_adapter.rb', line 160

def delete_media(message_id:, metadata: {})
  user_id = user_id_from()
  response = request(:delete, "/media/#{user_id}/#{path_id(message_id)}", allow_404: true)
  { success: response.fetch("success", false) }
end

#fetch_history(chat_id:, limit: 50, metadata: {}) ⇒ Object

Replays one chat's history through the service's live-capture normalizer and returns it synchronously (no queue, no webhook) — oldest-first, mapped exactly like fetch_inbound messages, including from_me/to on the operator's side of the conversation. History media arrives marked unavailable by design (media_error "history"): the service never bulk-downloads old media; live capture handles bytes going forward.



184
185
186
187
188
189
# File 'lib/whatsapp_notifier/web_adapter.rb', line 184

def fetch_history(chat_id:, limit: 50, metadata: {})
  user_id = user_id_from()
  body = { chatId: chat_id, limit: clamp_history_limit(limit) }
  response = request(:post, "/history/#{user_id}", body: body)
  Array(response["messages"]).map { |m| map_inbound_message(m) }
end

#fetch_inbound(metadata: {}) ⇒ Object

Drains the service's pending inbound queue for this user. The service returns the messages once, then clears them (at-least-once handoff — callers must dedupe on message_id). Accepts either a bare array or a { "messages" => [...] } envelope so the wire format can evolve.



103
104
105
106
107
108
# File 'lib/whatsapp_notifier/web_adapter.rb', line 103

def fetch_inbound(metadata: {})
  user_id = user_id_from()
  response = request(:get, "/inbound/#{user_id}")
  raw = response.is_a?(Hash) ? response["messages"] : response
  Array(raw).map { |m| map_inbound_message(m) }
end

#fetch_media(message_id:, metadata: {}) ⇒ Object

Fetches the raw bytes of a downloaded inbound media file. Returns { body:, mime:, filename:, size: } or nil when the service has no copy (never downloaded, swept by TTL, or already deleted).

Deliberately NOT routed through #request: that path JSON-parses the response body (and host apps are known to patch it further), which would corrupt binary payloads.

Raises:



117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/whatsapp_notifier/web_adapter.rb', line 117

def fetch_media(message_id:, metadata: {})
  user_id = user_id_from()
  res = binary_get("/media/#{user_id}/#{path_id(message_id)}")
  return nil if res.code.to_s == "404"
  raise ServiceError.new("service request failed (#{res.code}): #{res.body}", status: res.code) unless res.is_a?(Net::HTTPSuccess)

  body = res.body.to_s
  {
    body: body,
    mime: res["Content-Type"],
    filename: filename_from(res["Content-Disposition"]),
    size: body.bytesize
  }
end

#fetch_qr_code(metadata: {}) ⇒ Object



83
84
85
86
87
# File 'lib/whatsapp_notifier/web_adapter.rb', line 83

def fetch_qr_code(metadata: {})
  user_id = user_id_from()
  response = request(:get, "/qr/#{user_id}")
  response["qr"]
end

#list_chats(metadata: {}) ⇒ Object

Lists the paired number's 1:1 chats for history-sync discovery. Returns [{ id:, name:, last_message_at: }] newest-first; the service caps the list at its newest 500 and excludes groups/status/privacy chats. The route is token-gated like /media and raises the standard error on any non-2xx (401 when the user never paired or isn't ready).



171
172
173
174
175
# File 'lib/whatsapp_notifier/web_adapter.rb', line 171

def list_chats(metadata: {})
  user_id = user_id_from()
  response = request(:get, "/chats/#{user_id}")
  Array(response["chats"]).map { |chat| map_chat_summary(chat) }
end

#logout(metadata: {}) ⇒ Object

Logs the user out of WhatsApp and clears their saved session on the service.



213
214
215
216
217
# File 'lib/whatsapp_notifier/web_adapter.rb', line 213

def logout(metadata: {})
  user_id = user_id_from()
  response = request(:post, "/logout/#{user_id}")
  { success: response.fetch("success", false) }
end

#refetch_media(message_id:, chat_id:, metadata: {}) ⇒ Object

On-demand re-download (WhatsApp tap-to-download). The host calls this when an operator opens a media bubble whose bytes the service no longer holds (rolled off by the per-user cap or expired by TTL): the service re-pulls THAT one message's media and stores it, after which the host fetches it with the usual fetch_media GET. Returns { mime:, filename:, size:, status: } on success, or nil when the media is gone upstream (404) — same nil-on-404 contract as fetch_media, so a host that gets nil can grey the bubble out. A 0.7.0 service mid-rollout has no /refetch route and also answers 404 → nil, indistinguishable from gone, which is the safe degrade.



141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/whatsapp_notifier/web_adapter.rb', line 141

def refetch_media(message_id:, chat_id:, metadata: {})
  user_id = user_id_from()
  body = { messageId: message_id, chatId: chat_id }
  response = request(:post, "/media/#{user_id}/refetch", body: body, allow_404: true)
  return nil unless response["success"]

  {
    mime: response["mediaMime"] || response["media_mime"],
    filename: response["mediaFilename"] || response["media_filename"],
    size: response["mediaSize"] || response["media_size"],
    status: response["mediaStatus"] || response["media_status"]
  }
end

#resolve_lid(lid:, metadata: {}) ⇒ Object

Resolves ONE WhatsApp privacy id (@lid) to the phone number behind it, returning the bare digits or nil when nothing is recoverable.

Hosts need this to repair conversations they keyed on an unresolved privacy id — rows written when the service still fabricated a "phone" out of the LID's own digits (fixed in 0.9.3). Live capture resolves on its own; this is the backward-looking half.

nil is a real answer ("no phone behind this id"), not an error: the service replies 200 with pn: null, and only a transport/gate failure raises.



202
203
204
205
206
207
208
209
210
# File 'lib/whatsapp_notifier/web_adapter.rb', line 202

def resolve_lid(lid:, metadata: {})
  user_id = user_id_from()
  digits = lid.to_s.split("@").first.to_s.gsub(/\D/, "")
  return nil if digits.empty?

  response = request(:get, "/contacts/lid/#{user_id}?lid=#{digits}")
  resolved = response["pn"].to_s.split("@").first.to_s.gsub(/\D/, "")
  resolved.empty? ? nil : resolved
end

#send_message(payload:, session: {}) ⇒ Object



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/whatsapp_notifier/web_adapter.rb', line 57

def send_message(payload:, session: {})
  user_id = user_id_from(payload[:metadata] || {})
  body = {
    to: payload[:to],
    message: payload[:body],
    mediaUrl: payload.dig(:metadata, :media_url)
  }.compact

  response = request(:post, "/send/#{user_id}", body: body)
  {
    success: response.fetch("success"),
    # Prefer the service-issued WhatsApp message id (0.8.0): it is the key
    # the host dedupes the send's own fromMe echo on, so a real id must
    # win over the locally fabricated one. The fallback keeps 0.7.0
    # services (no messageId in the response) working unchanged.
    message_id: response["messageId"] || response["message_id"] ||
                payload[:idempotency_key] || "local-#{Time.now.to_i}",
    session: session,
    # A code the service supplied wins over anything the provider could
    # infer from the message text. Absent on every service ≤ 0.8.x, which
    # is why the provider still classifies as a fallback.
    error_code: ErrorCode.normalize(response["errorCode"] || response["error_code"]),
    error_message: response["error"]
  }
end