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]
}.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.



44
45
46
47
48
49
50
# File 'lib/whatsapp_notifier/web_adapter.rb', line 44

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



40
41
42
# File 'lib/whatsapp_notifier/web_adapter.rb', line 40

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



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

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.



151
152
153
154
155
# File 'lib/whatsapp_notifier/web_adapter.rb', line 151

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.



175
176
177
178
179
180
# File 'lib/whatsapp_notifier/web_adapter.rb', line 175

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.



94
95
96
97
98
99
# File 'lib/whatsapp_notifier/web_adapter.rb', line 94

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.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/whatsapp_notifier/web_adapter.rb', line 108

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 "service request failed (#{res.code}): #{res.body}" 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



74
75
76
77
78
# File 'lib/whatsapp_notifier/web_adapter.rb', line 74

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).



162
163
164
165
166
# File 'lib/whatsapp_notifier/web_adapter.rb', line 162

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.



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

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.



132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/whatsapp_notifier/web_adapter.rb', line 132

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

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



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/whatsapp_notifier/web_adapter.rb', line 52

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,
    error_message: response["error"]
  }
end