melaya 0.1.4 → 0.3.0

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.
@@ -4,69 +4,253 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
  require "openssl"
7
+ require "securerandom"
7
8
 
8
9
  require_relative "errors"
9
10
 
10
11
  module Melaya
11
- # Internal HTTP client. Injects the API key on every call as both
12
- # a query-param (?apiKey=) and Authorization: Bearer header.
12
+ # Internal HTTP client. Supports Bearer JWT *and* mk_* platform API key.
13
+ # The credential is sent ONLY via the Authorization header — never in the URL
14
+ # query string, so it cannot leak into access logs or proxies. TLS is
15
+ # enforced by default; never log secrets.
16
+ #
17
+ # Retry policy: bounded exponential back-off with jitter on network errors,
18
+ # 429, and 5xx — but ONLY for idempotent GET requests (max 2 retries).
19
+ # POST/PUT/PATCH/DELETE are never retried. Retry-After header is honoured
20
+ # on 429. Per-request timeout default: 30 seconds (configurable).
13
21
  class HttpClient
14
- DEFAULT_BASE_URL = "https://api.melaya.org"
22
+ DEFAULT_BASE_URL = "https://api.melaya.org"
23
+ DEFAULT_TIMEOUT_MS = 30_000 # milliseconds
15
24
 
16
- def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true)
17
- @api_key = api_key
25
+ # Maximum additional retries after the first attempt (2 retries = 3 total
26
+ # attempts) for idempotent GET requests only.
27
+ MAX_GET_RETRIES = 2
28
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
29
+
30
+ # @param api_key [String] mk_* platform key or Bearer JWT
31
+ # @param base_url [String]
32
+ # @param verify_ssl [Boolean]
33
+ # @param timeout_ms [Integer] per-request timeout in milliseconds (default 30 000)
34
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true,
35
+ timeout_ms: DEFAULT_TIMEOUT_MS)
36
+ raise ArgumentError, "Melaya: TLS certificate verification cannot be disabled." unless verify_ssl
37
+ # Never store in a way that could leak to logs accidentally — keep as
38
+ # an opaque token string only accessible through the private accessor.
39
+ @_tok = api_key.freeze
18
40
  @base_uri = URI.parse(base_url.chomp("/"))
19
- @verify_ssl = verify_ssl
41
+ @verify_ssl = true
42
+ @timeout_s = (timeout_ms / 1000.0).ceil
43
+ end
44
+
45
+ # ── Public verb helpers ────────────────────────────────────────────────────
46
+ #
47
+ # Every verb accepts an optional trailing +timeout_s+ override for that
48
+ # single call (e.g. a slow RAG ingest job); it defaults to the client's own
49
+ # timeout. NOTE: it is a plain positional parameter, not a keyword — many
50
+ # call sites pass a bare `"key" => value` Hash literal as +body+/+params+,
51
+ # and Ruby 3's keyword/Hash separation would otherwise raise
52
+ # "unknown keyword" on every one of them if this were `timeout_s:`.
53
+
54
+ def get(path, params = {}, timeout_s = nil)
55
+ request(:get, path, params: params, timeout_s: timeout_s)
56
+ end
57
+
58
+ def post(path, body = nil, timeout_s = nil)
59
+ request(:post, path, body: body, timeout_s: timeout_s)
60
+ end
61
+
62
+ def put(path, body = nil, timeout_s = nil)
63
+ request(:put, path, body: body, timeout_s: timeout_s)
20
64
  end
21
65
 
22
- def get(path, params = {})
23
- request(:get, path, params: params)
66
+ def patch(path, body = nil, timeout_s = nil)
67
+ request(:patch, path, body: body, timeout_s: timeout_s)
24
68
  end
25
69
 
26
- def post(path, body = nil)
27
- request(:post, path, body: body)
70
+ # +body+ is optional: some bridged DELETE routes take structured input
71
+ # (e.g. googleDisconnect's { accountId, capability? }) that should not be
72
+ # exposed in a URL, so it travels as a JSON body instead of query params.
73
+ def delete(path, params = {}, body = nil, timeout_s = nil)
74
+ request(:delete, path, params: params, body: body, timeout_s: timeout_s)
28
75
  end
29
76
 
30
- def delete(path, params = {})
31
- request(:delete, path, params: params)
77
+ # GET that returns the RAW response body (String, binary encoding) instead
78
+ # of JSON-parsing it — for binary downloads like +runInputFile+. Errors are
79
+ # still parsed and raised exactly like the JSON verb helpers. Retried like
80
+ # any other GET (idempotent).
81
+ def get_bytes(path, params = {}, timeout_s = nil)
82
+ request(:get, path, params: params, timeout_s: timeout_s, raw: true)
83
+ end
84
+
85
+ # Upload a single file as `multipart/form-data` with one file part named
86
+ # +field_name+. Never retried (a partial multipart re-send could double an
87
+ # upload with side effects), and the response is parsed exactly like the
88
+ # JSON POST helper (same error type).
89
+ #
90
+ # @param path [String]
91
+ # @param query [Hash] query-string params (e.g. { "key" => ..., "project" => ... })
92
+ # @param field_name [String] the multipart field name the server expects (e.g. "file")
93
+ # @param bytes [String] raw file content
94
+ # @param filename [String] filename reported in the part's Content-Disposition
95
+ # @param content_type [String, nil] defaults to "application/octet-stream"
96
+ def post_multipart(path, query, field_name, bytes, filename, content_type = nil)
97
+ uri = build_uri(path, query || {})
98
+ boundary = "MelayaFormBoundary#{SecureRandom.hex(16)}"
99
+
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = uri.scheme == "https"
102
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
103
+ http.open_timeout = @timeout_s
104
+ http.read_timeout = @timeout_s
105
+
106
+ req = Net::HTTP::Post.new(uri)
107
+ req["Authorization"] = "Bearer #{@_tok}"
108
+ req["Accept"] = "application/json"
109
+ req["User-Agent"] = "melaya-ruby/#{Melaya::VERSION}"
110
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
111
+ req.body = multipart_body(boundary, field_name, bytes, filename, content_type)
112
+
113
+ resp = http.request(req)
114
+ parse(resp)
32
115
  end
33
116
 
34
117
  private
35
118
 
119
+ # Builds a single-file multipart/form-data body by hand (no dependency on
120
+ # any multipart-encoding gem).
121
+ def multipart_body(boundary, field_name, bytes, filename, content_type)
122
+ ct = content_type || "application/octet-stream"
123
+ head =
124
+ "--#{boundary}\r\n" \
125
+ "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{escape_multipart_value(filename)}\"\r\n" \
126
+ "Content-Type: #{ct}\r\n\r\n"
127
+ tail = "\r\n--#{boundary}--\r\n"
128
+ (head.b + bytes.to_s.b + tail.b)
129
+ end
130
+
131
+ # Escapes double quotes / newlines out of a Content-Disposition value.
132
+ def escape_multipart_value(value)
133
+ value.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').tr("\r\n", " ")
134
+ end
135
+
36
136
  def build_uri(path, params = {})
37
137
  uri = URI.parse("#{@base_uri}#{path}")
38
- query = { "apiKey" => @api_key }
138
+ # SECURITY: the credential is never placed in the query string; it is
139
+ # sent only via the Authorization header (see make_request).
140
+ query = {}
39
141
  params.each { |k, v| query[k.to_s] = v.to_s unless v.nil? }
40
- uri.query = URI.encode_www_form(query)
142
+ uri.query = URI.encode_www_form(query) unless query.empty?
41
143
  uri
42
144
  end
43
145
 
44
- def request(method, path, params: {}, body: nil)
45
- uri = build_uri(path, params)
46
-
47
- http = Net::HTTP.new(uri.host, uri.port)
48
- http.use_ssl = uri.scheme == "https"
49
- http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
50
- http.open_timeout = 15
51
- http.read_timeout = 60
52
-
146
+ def make_request(method, uri, body)
53
147
  req = case method
54
148
  when :get then Net::HTTP::Get.new(uri)
55
149
  when :post then Net::HTTP::Post.new(uri)
150
+ when :put then Net::HTTP::Put.new(uri)
151
+ when :patch then Net::HTTP::Patch.new(uri)
56
152
  when :delete then Net::HTTP::Delete.new(uri)
57
153
  else raise ArgumentError, "Unknown HTTP method: #{method}"
58
154
  end
59
155
 
60
- req["Authorization"] = "Bearer #{@api_key}"
156
+ # Authorization: never expose token in error output below
157
+ req["Authorization"] = "Bearer #{@_tok}"
61
158
  req["Accept"] = "application/json"
159
+ req["User-Agent"] = "melaya-ruby/#{Melaya::VERSION}"
62
160
 
63
161
  if body
64
162
  req["Content-Type"] = "application/json"
65
163
  req.body = JSON.generate(body)
66
164
  end
67
165
 
68
- resp = http.request(req)
69
- parse(resp)
166
+ req
167
+ end
168
+
169
+ def request(method, path, params: {}, body: nil, timeout_s: nil, raw: false)
170
+ uri = build_uri(path, params)
171
+
172
+ eff_timeout = timeout_s ? [timeout_s.to_f, 0.001].max.ceil : @timeout_s
173
+
174
+ http = Net::HTTP.new(uri.host, uri.port)
175
+ http.use_ssl = uri.scheme == "https"
176
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
177
+ http.open_timeout = eff_timeout
178
+ http.read_timeout = eff_timeout
179
+
180
+ # Only GET requests are retried (idempotent); all mutating verbs fail fast.
181
+ retryable = (method == :get)
182
+ attempt = 0
183
+
184
+ retry_after_hdr = nil
185
+ begin
186
+ attempt += 1
187
+ retry_after_hdr = nil # reset on each attempt
188
+ req = make_request(method, uri, body)
189
+ resp = http.request(req)
190
+ # Snapshot Retry-After before parse() consumes the response object,
191
+ # so we can honour the header even after the MelayaError is raised.
192
+ retry_after_hdr = resp["retry-after"] || resp["Retry-After"]
193
+ raw ? parse_raw(resp) : parse(resp)
194
+ rescue MelayaError => e
195
+ if retryable && RETRY_STATUSES.include?(e.status) && attempt <= MAX_GET_RETRIES
196
+ # Build a minimal resp-like object carrying only the header we need,
197
+ # so _backoff_delay can honour Retry-After without holding the socket.
198
+ hdr_carrier = { "retry-after" => retry_after_hdr }
199
+ delay = _backoff_delay(attempt, e, hdr_carrier)
200
+ sleep(delay)
201
+ retry
202
+ end
203
+ raise
204
+ rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout
205
+ raise unless retryable && attempt <= MAX_GET_RETRIES
206
+ sleep(_backoff_delay(attempt, nil))
207
+ retry
208
+ end
209
+ end
210
+
211
+ # Exponential backoff with ±25 % jitter; honours Retry-After on 429.
212
+ # Base: 2^(attempt-1) seconds, capped at 16 s before jitter.
213
+ #
214
+ # Retry-After resolution order (first match wins):
215
+ # 1. HTTP `Retry-After` response header — seconds integer or HTTP-date
216
+ # 2. JSON body `retryAfter` / `retry_after` field (legacy fallback)
217
+ # 3. Exponential back-off
218
+ def _backoff_delay(attempt, err, resp = nil)
219
+ if err.is_a?(MelayaError) && err.status == 429
220
+ # 1. HTTP Retry-After header (preferred, RFC 7231)
221
+ if resp.respond_to?(:[]) && (ra_hdr = resp["retry-after"] || resp["Retry-After"])
222
+ secs = _parse_retry_after_header(ra_hdr)
223
+ return [secs, 0.5].max if secs
224
+ end
225
+
226
+ # 2. JSON body fallback ("retryAfter" or "retry_after")
227
+ if err.respond_to?(:body) && err.body.is_a?(Hash)
228
+ ra = err.body["retryAfter"] || err.body["retry_after"]
229
+ return [ra.to_f, 0.5].max if ra
230
+ end
231
+ end
232
+ base = [2**(attempt - 1), 16].min.to_f
233
+ jitter = base * 0.25 * (rand - 0.5) * 2 # ±25 %
234
+ [base + jitter, 0.1].max
235
+ end
236
+
237
+ # Parse an RFC 7231 Retry-After value: either a delay-seconds integer
238
+ # or an HTTP-date string. Returns seconds as Float, or nil if unparseable.
239
+ def _parse_retry_after_header(value)
240
+ str = value.to_s.strip
241
+ # Delay-seconds: plain non-negative integer
242
+ if str =~ /\A\d+\z/
243
+ return str.to_f
244
+ end
245
+ # HTTP-date (e.g. "Wed, 21 Oct 2099 07:28:00 GMT")
246
+ begin
247
+ require "time"
248
+ target = Time.httpdate(str)
249
+ delay = target - Time.now
250
+ return [delay, 0.0].max
251
+ rescue ArgumentError, TypeError
252
+ nil
253
+ end
70
254
  end
71
255
 
72
256
  def parse(resp)
@@ -77,21 +261,56 @@ module Melaya
77
261
  text
78
262
  end
79
263
 
80
- if resp.code.to_i >= 400
81
- code = data.is_a?(Hash) ? data["error"] : nil
82
- msg = "Melaya API #{resp.code}" + (code ? " (#{code})" : "")
83
- raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
84
- end
264
+ status = resp.code.to_i
265
+ raise_for_status!(resp, data, status) if status >= 400
85
266
 
86
- # The API wraps every payload in { "ok": true/false, ... }.
87
- # ok:false is a request-level failure — raise instead of returning silently.
267
+ # The API may wrap payload in { "ok": false, ... } for request-level failures.
88
268
  if data.is_a?(Hash) && data["ok"] == false
89
- code = data["error"]
90
- msg = "Melaya API request failed" + (code ? ": #{code}" : "")
91
- raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
269
+ err_code = data["error"]
270
+ msg = "Melaya API request failed" + (err_code ? ": #{err_code}" : "")
271
+ raise MelayaError.new(msg, status: resp.code.to_i, code: err_code, body: data)
92
272
  end
93
273
 
94
274
  data
95
275
  end
276
+
277
+ # Like +parse+, but for a raw binary body (a file download): on success
278
+ # the response body is returned unparsed; on error the same JSON error
279
+ # envelopes and exception types apply.
280
+ def parse_raw(resp)
281
+ status = resp.code.to_i
282
+ if status >= 400
283
+ text = resp.body.to_s.strip
284
+ data = begin
285
+ text.empty? ? nil : JSON.parse(text)
286
+ rescue JSON::ParserError
287
+ text
288
+ end
289
+ raise_for_status!(resp, data, status)
290
+ end
291
+ resp.body.to_s
292
+ end
293
+
294
+ # Shared 4xx/5xx handling for both +parse+ and +parse_raw+. Two error
295
+ # envelope shapes:
296
+ # 1. { error: 'tier_insufficient', tier: '...' } -> 403
297
+ # 2. { error: '...', message: '...', code: '...' }
298
+ # Extract error code safely — never echo raw body in message.
299
+ def raise_for_status!(resp, data, status)
300
+ err_code = data.is_a?(Hash) ? data["error"] : nil
301
+
302
+ if status == 403 && err_code == "tier_insufficient"
303
+ raise TierInsufficientError.new(tier: data.is_a?(Hash) ? data["tier"] : nil, body: data)
304
+ end
305
+ if status == 429
306
+ # Raised here; the GET retry loop above may swallow-and-retry it —
307
+ # callers only see it once retries are exhausted.
308
+ ra = _parse_retry_after_header(resp["retry-after"] || resp["Retry-After"])
309
+ raise RateLimitError.new(retry_after: ra, body: data)
310
+ end
311
+
312
+ msg = "Melaya API #{resp.code}" + (err_code ? " (#{err_code})" : "")
313
+ raise MelayaError.new(msg, status: status, code: err_code, body: data)
314
+ end
96
315
  end
97
316
  end
data/lib/melaya/market.rb CHANGED
@@ -147,6 +147,48 @@ module Melaya
147
147
  @http.get("/api/v1/public/catalog-counts")
148
148
  end
149
149
 
150
+ # ── restRoutes.ts market endpoints ──────────────────────────────────────────
151
+
152
+ # POST /api/v1/private/market/liquidations
153
+ # Get aggregated CEX liquidation data. (requireAuth)
154
+ # @param params [Hash] e.g. exchange, symbol, since_ms
155
+ def cex_liquidations(params = {})
156
+ @http.post("/api/v1/private/market/liquidations", params)
157
+ end
158
+
159
+ # GET /api/v1/market/mdd-pairs (public)
160
+ # Get max-drawdown pairs list (public screener data).
161
+ def mdd_pairs
162
+ @http.get("/api/v1/market/mdd-pairs")
163
+ end
164
+
165
+ # GET /api/v1/private/market/onchain-yields (Forge+ tier)
166
+ # Get on-chain yield data.
167
+ # @param params [Hash]
168
+ def onchain_yields(params = {})
169
+ @http.get("/api/v1/private/market/onchain-yields", params)
170
+ end
171
+
172
+ # GET /api/v1/private/market/onchain-liquidity (Forge+ tier)
173
+ # Get on-chain liquidity data.
174
+ # @param params [Hash]
175
+ def onchain_liquidity(params = {})
176
+ @http.get("/api/v1/private/market/onchain-liquidity", params)
177
+ end
178
+
179
+ # GET /api/v1/market/banner (public)
180
+ # Get marketing/notification banner content.
181
+ def banner
182
+ @http.get("/api/v1/market/banner")
183
+ end
184
+
185
+ # GET /api/v1/market/price-history (public)
186
+ # Get price history for chart display.
187
+ # @param params [Hash] e.g. exchange, symbol, timeframe
188
+ def price_history(params = {})
189
+ @http.get("/api/v1/market/price-history", params)
190
+ end
191
+
150
192
  private
151
193
 
152
194
  def compact(hash)
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Melaya
4
+ # ── Domain namespace objects ─────────────────────────────────────────────────
5
+ #
6
+ # Three read-only namespace objects hang off every +Melaya::Client+ instance,
7
+ # grouping the flat module accessors into logical planes:
8
+ #
9
+ # melaya.trading — market data, account, sim, strategies, backtest, stream, trade
10
+ # melaya.agents — pipelines/runs, hitl, assistant, phone, evals, models, connector_tools
11
+ # melaya.platform — projects, credentials, connectors, billing, team, templates,
12
+ # overview (via pipelines), runner, auth, mfa (via auth),
13
+ # accounts, bugs, events
14
+ #
15
+ # Every attribute on these objects is the *same* instance that is also reachable
16
+ # via the flat accessor on the client, so there is no duplication of state and
17
+ # no double HTTP calls.
18
+ #
19
+ # @example
20
+ # melaya = Melaya::Client.new(api_key: ENV["MELAYA_API_KEY"])
21
+ #
22
+ # # Namespaced (primary API)
23
+ # melaya.trading.market.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot")
24
+ # melaya.agents.pipelines.list(project: "my-project")
25
+ # melaya.platform.projects.list
26
+ #
27
+ # # Flat aliases still work (backward-compatible)
28
+ # melaya.market.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot")
29
+
30
+ # Namespace grouping all trading-plane modules.
31
+ #
32
+ # Modules:
33
+ # - +market+ — REST market-data + reference endpoints (public + authenticated).
34
+ # - +account+ — Authenticated account reads: connected keys, tier limits, usage.
35
+ # - +sim+ — Paper trading (sim broker): virtual balance, positions, and orders.
36
+ # - +strategies+ — Launch, control, and inspect trading strategies (paper + live).
37
+ # - +backtest+ — Historical backtests + parameter sweeps on the Rust engine.
38
+ # - +stream+ — WebSocket streaming endpoints (public market data + private feeds).
39
+ # - +trade+ — Live trading — credentialed order placement on a connected exchange.
40
+ TradingNamespace = Struct.new(
41
+ :market,
42
+ :account,
43
+ :sim,
44
+ :strategies,
45
+ :backtest,
46
+ :stream,
47
+ :trade,
48
+ keyword_init: true
49
+ )
50
+
51
+ # Namespace grouping all agent-plane modules.
52
+ #
53
+ # Modules:
54
+ # - +pipelines+ — Pipeline runs, traces, schedules, and overview dashboard.
55
+ # (also aliased as +runs+ for ergonomics)
56
+ # - +hitl+ — Human-in-the-loop approval queue: list pending, approve, reject.
57
+ # - +assistant+ — Assistant onboarding profile (get + set).
58
+ # - +phone+ — Phone device control: pair, list, screen-tree, apps.
59
+ # - +evals+ — Agent evaluation runs and benchmarks.
60
+ # - +models+ — AI model list (reached via credentials#list_models; this is
61
+ # the CredentialsAPI instance filtered by convention — call
62
+ # +models.list_models(provider: "anthropic")+ etc.)
63
+ # - +connector_tools+ — Call already-connected connector tools directly
64
+ # (Gmail, Slack, Stripe, ...): list, search, describe, test,
65
+ # connect, call, call_status, call_and_wait.
66
+ AgentsNamespace = Struct.new(
67
+ :pipelines,
68
+ :hitl,
69
+ :assistant,
70
+ :phone,
71
+ :evals,
72
+ :models,
73
+ :connector_tools,
74
+ keyword_init: true
75
+ ) do
76
+ # +runs+ is an ergonomic alias for +pipelines+ (agents call them "runs").
77
+ def runs
78
+ pipelines
79
+ end
80
+ end
81
+
82
+ # Namespace grouping all platform-plane modules.
83
+ #
84
+ # Modules:
85
+ # - +projects+ — Create and list agent projects.
86
+ # - +credentials+ — User-scoped credential storage (services, OAuth, env handles, models).
87
+ # - +connectors+ — Project-scoped connector credentials.
88
+ # - +billing+ — Subscription, Stripe checkout/portal, pricing plans, credit balances.
89
+ # - +team+ — Project team management: members, roles, invite links.
90
+ # - +templates+ — Pipeline templates: create, share, assign, and manage visibility.
91
+ # - +overview+ — Pipeline overview dashboard (same object as +agents.pipelines+,
92
+ # exposed here for discoverability on the platform plane).
93
+ # - +runner+ — Runner tokens: mint, list, revoke +mel_run_+ tokens.
94
+ # - +auth+ — Login, MFA, registration, password management, session tokens.
95
+ # - +mfa+ — Alias for +auth+ (MFA operations live on the same AuthAPI object).
96
+ # - +accounts+ — Account management: GDPR export, CEX key removal, profile updates.
97
+ # - +bugs+ — Bug reports: submit, track, and comment.
98
+ # - +events+ — Platform real-time events over Socket.IO.
99
+ PlatformNamespace = Struct.new(
100
+ :projects,
101
+ :credentials,
102
+ :connectors,
103
+ :billing,
104
+ :team,
105
+ :templates,
106
+ :overview,
107
+ :runner,
108
+ :auth,
109
+ :accounts,
110
+ :bugs,
111
+ :events,
112
+ keyword_init: true
113
+ ) do
114
+ # +mfa+ is an ergonomic alias for +auth+ (MFA methods live on AuthAPI).
115
+ def mfa
116
+ auth
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Melaya
4
+ # Phone API — pair and control Android devices connected to the Melaya runner.
5
+ #
6
+ # Agents use these endpoints to drive a paired phone (tap, type, read
7
+ # screen state, launch apps) via the Melaya APK.
8
+ #
9
+ # Maps to /api/v1/private/phone/*.
10
+ #
11
+ # @example
12
+ # result = melaya.phone.pair
13
+ # puts "Pairing code: #{result["code"]}"
14
+ # # User enters code in the Melaya APK on the phone
15
+ #
16
+ # devices = melaya.phone.list_devices
17
+ # tree = melaya.phone.screen_tree
18
+ class PhoneAPI
19
+ def initialize(http)
20
+ @http = http
21
+ end
22
+
23
+ # POST /api/v1/private/phone/pair
24
+ # Start phone device pairing — generates a pairing code for the Melaya APK.
25
+ def pair
26
+ @http.post("/api/v1/private/phone/pair")
27
+ end
28
+
29
+ # GET /api/v1/private/phone/devices
30
+ # List all paired phone devices for the authenticated user.
31
+ def list_devices
32
+ @http.get("/api/v1/private/phone/devices").fetch("devices", [])
33
+ end
34
+
35
+ # DELETE /api/v1/private/phone/devices/:deviceId
36
+ # Revoke a paired phone device by ID.
37
+ # @param device_id [String]
38
+ def revoke_device(device_id)
39
+ @http.delete("/api/v1/private/phone/devices/#{enc(device_id)}")
40
+ end
41
+
42
+ # GET /api/v1/private/phone/screen-tree
43
+ # Get the current accessibility tree from the paired phone's screen.
44
+ def screen_tree
45
+ @http.get("/api/v1/private/phone/screen-tree")
46
+ end
47
+
48
+ # GET /api/v1/private/phone/apps
49
+ # List installed apps on the paired phone.
50
+ def list_apps
51
+ @http.get("/api/v1/private/phone/apps").dig("result", "apps") || []
52
+ end
53
+
54
+ # PUT /api/v1/private/phone/apps/allowed
55
+ # Set the allowlist of apps that agents are permitted to interact with.
56
+ # @param package_names [Array<String>]
57
+ def set_allowed_apps(package_names)
58
+ apps = package_names.map { |package| { "package" => package } }
59
+ @http.put("/api/v1/private/phone/apps/allowed", "apps" => apps)
60
+ end
61
+
62
+ # POST /api/v1/private/phone/active-run
63
+ # Register the currently active pipeline run on the phone (used by agents).
64
+ # @param run_id [String]
65
+ def register_active_run(run_id)
66
+ @http.post("/api/v1/private/phone/active-run", "runId" => run_id)
67
+ end
68
+
69
+ # POST /api/v1/private/phone/apps/grant
70
+ # Grant ONE app into the agent allowlist (atomic append) — e.g. from an
71
+ # in-chat "allow this app" approval card, without replacing the whole list.
72
+ # @param package [String] Android package name
73
+ # @param label [String, nil]
74
+ def grant_app(package, label: nil)
75
+ body = compact("package" => package, "label" => label)
76
+ @http.post("/api/v1/private/phone/apps/grant", body)
77
+ end
78
+
79
+ # POST /api/v1/private/phone/request-cast
80
+ # Re-cast the phone screen (re-triggers the MediaProjection consent
81
+ # prompt) from the desktop mirror.
82
+ # @param device_id [String, nil]
83
+ def request_cast(device_id: nil)
84
+ body = compact("deviceId" => device_id)
85
+ @http.post("/api/v1/private/phone/request-cast", body.empty? ? nil : body)
86
+ end
87
+
88
+ private
89
+
90
+ def enc(s)
91
+ URI.encode_www_form_component(s.to_s)
92
+ end
93
+
94
+ def compact(hash)
95
+ hash.reject { |_, v| v.nil? }
96
+ end
97
+ end
98
+ end