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



84
85
86
87
88
89
90
91
92
# File 'lib/whatsapp_notifier/web_adapter.rb', line 84

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.



155
156
157
158
159
# File 'lib/whatsapp_notifier/web_adapter.rb', line 155

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.



179
180
181
182
183
184
# File 'lib/whatsapp_notifier/web_adapter.rb', line 179

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.



98
99
100
101
102
103
# File 'lib/whatsapp_notifier/web_adapter.rb', line 98

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:



112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/whatsapp_notifier/web_adapter.rb', line 112

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



78
79
80
81
82
# File 'lib/whatsapp_notifier/web_adapter.rb', line 78

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



166
167
168
169
170
# File 'lib/whatsapp_notifier/web_adapter.rb', line 166

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.



187
188
189
190
191
# File 'lib/whatsapp_notifier/web_adapter.rb', line 187

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.



136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/whatsapp_notifier/web_adapter.rb', line 136

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
73
74
75
76
# 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,
    # 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