melaya 0.1.4 → 0.2.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.
- checksums.yaml +4 -4
- data/LICENSE +158 -0
- data/README.md +96 -15
- data/lib/melaya/accounts.rb +42 -0
- data/lib/melaya/assistant.rb +41 -0
- data/lib/melaya/auth.rb +137 -0
- data/lib/melaya/backtest.rb +38 -0
- data/lib/melaya/billing.rb +75 -0
- data/lib/melaya/bugs.rb +67 -0
- data/lib/melaya/connectors.rb +71 -0
- data/lib/melaya/credentials.rb +255 -0
- data/lib/melaya/errors.rb +36 -2
- data/lib/melaya/evals.rb +78 -0
- data/lib/melaya/events.rb +425 -0
- data/lib/melaya/hitl.rb +114 -0
- data/lib/melaya/http_client.rb +159 -30
- data/lib/melaya/market.rb +42 -0
- data/lib/melaya/namespaces.rb +115 -0
- data/lib/melaya/phone.rb +75 -0
- data/lib/melaya/pipelines.rb +342 -0
- data/lib/melaya/projects.rb +58 -0
- data/lib/melaya/runner.rb +44 -0
- data/lib/melaya/strategies.rb +15 -0
- data/lib/melaya/stream.rb +5 -3
- data/lib/melaya/team.rb +102 -0
- data/lib/melaya/templates.rb +148 -0
- data/lib/melaya/version.rb +1 -1
- data/lib/melaya.rb +261 -30
- data/melaya.gemspec +10 -5
- metadata +28 -7
data/lib/melaya/http_client.rb
CHANGED
|
@@ -8,17 +8,41 @@ require "openssl"
|
|
|
8
8
|
require_relative "errors"
|
|
9
9
|
|
|
10
10
|
module Melaya
|
|
11
|
-
# Internal HTTP client.
|
|
12
|
-
#
|
|
11
|
+
# Internal HTTP client. Supports Bearer JWT *and* mk_* platform API key.
|
|
12
|
+
# The credential is sent ONLY via the Authorization header — never in the URL
|
|
13
|
+
# query string, so it cannot leak into access logs or proxies. TLS is
|
|
14
|
+
# enforced by default; never log secrets.
|
|
15
|
+
#
|
|
16
|
+
# Retry policy: bounded exponential back-off with jitter on network errors,
|
|
17
|
+
# 429, and 5xx — but ONLY for idempotent GET requests (max 2 retries).
|
|
18
|
+
# POST/PUT/PATCH/DELETE are never retried. Retry-After header is honoured
|
|
19
|
+
# on 429. Per-request timeout default: 30 seconds (configurable).
|
|
13
20
|
class HttpClient
|
|
14
|
-
DEFAULT_BASE_URL
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.melaya.org"
|
|
22
|
+
DEFAULT_TIMEOUT_MS = 30_000 # milliseconds
|
|
23
|
+
|
|
24
|
+
# Maximum additional retries after the first attempt (2 retries = 3 total
|
|
25
|
+
# attempts) for idempotent GET requests only.
|
|
26
|
+
MAX_GET_RETRIES = 2
|
|
27
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
28
|
+
|
|
29
|
+
# @param api_key [String] mk_* platform key or Bearer JWT
|
|
30
|
+
# @param base_url [String]
|
|
31
|
+
# @param verify_ssl [Boolean]
|
|
32
|
+
# @param timeout_ms [Integer] per-request timeout in milliseconds (default 30 000)
|
|
33
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true,
|
|
34
|
+
timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
35
|
+
raise ArgumentError, "Melaya: TLS certificate verification cannot be disabled." unless verify_ssl
|
|
36
|
+
# Never store in a way that could leak to logs accidentally — keep as
|
|
37
|
+
# an opaque token string only accessible through the private accessor.
|
|
38
|
+
@_tok = api_key.freeze
|
|
18
39
|
@base_uri = URI.parse(base_url.chomp("/"))
|
|
19
|
-
@verify_ssl =
|
|
40
|
+
@verify_ssl = true
|
|
41
|
+
@timeout_s = (timeout_ms / 1000.0).ceil
|
|
20
42
|
end
|
|
21
43
|
|
|
44
|
+
# ── Public verb helpers ────────────────────────────────────────────────────
|
|
45
|
+
|
|
22
46
|
def get(path, params = {})
|
|
23
47
|
request(:get, path, params: params)
|
|
24
48
|
end
|
|
@@ -27,6 +51,14 @@ module Melaya
|
|
|
27
51
|
request(:post, path, body: body)
|
|
28
52
|
end
|
|
29
53
|
|
|
54
|
+
def put(path, body = nil)
|
|
55
|
+
request(:put, path, body: body)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def patch(path, body = nil)
|
|
59
|
+
request(:patch, path, body: body)
|
|
60
|
+
end
|
|
61
|
+
|
|
30
62
|
def delete(path, params = {})
|
|
31
63
|
request(:delete, path, params: params)
|
|
32
64
|
end
|
|
@@ -35,38 +67,120 @@ module Melaya
|
|
|
35
67
|
|
|
36
68
|
def build_uri(path, params = {})
|
|
37
69
|
uri = URI.parse("#{@base_uri}#{path}")
|
|
38
|
-
|
|
70
|
+
# SECURITY: the credential is never placed in the query string; it is
|
|
71
|
+
# sent only via the Authorization header (see make_request).
|
|
72
|
+
query = {}
|
|
39
73
|
params.each { |k, v| query[k.to_s] = v.to_s unless v.nil? }
|
|
40
|
-
uri.query = URI.encode_www_form(query)
|
|
74
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
41
75
|
uri
|
|
42
76
|
end
|
|
43
77
|
|
|
44
|
-
def
|
|
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
|
-
|
|
78
|
+
def make_request(method, uri, body)
|
|
53
79
|
req = case method
|
|
54
80
|
when :get then Net::HTTP::Get.new(uri)
|
|
55
81
|
when :post then Net::HTTP::Post.new(uri)
|
|
82
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
83
|
+
when :patch then Net::HTTP::Patch.new(uri)
|
|
56
84
|
when :delete then Net::HTTP::Delete.new(uri)
|
|
57
85
|
else raise ArgumentError, "Unknown HTTP method: #{method}"
|
|
58
86
|
end
|
|
59
87
|
|
|
60
|
-
|
|
88
|
+
# Authorization: never expose token in error output below
|
|
89
|
+
req["Authorization"] = "Bearer #{@_tok}"
|
|
61
90
|
req["Accept"] = "application/json"
|
|
91
|
+
req["User-Agent"] = "melaya-ruby/#{Melaya::VERSION}"
|
|
62
92
|
|
|
63
93
|
if body
|
|
64
94
|
req["Content-Type"] = "application/json"
|
|
65
95
|
req.body = JSON.generate(body)
|
|
66
96
|
end
|
|
67
97
|
|
|
68
|
-
|
|
69
|
-
|
|
98
|
+
req
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request(method, path, params: {}, body: nil)
|
|
102
|
+
uri = build_uri(path, params)
|
|
103
|
+
|
|
104
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
105
|
+
http.use_ssl = uri.scheme == "https"
|
|
106
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
107
|
+
http.open_timeout = @timeout_s
|
|
108
|
+
http.read_timeout = @timeout_s
|
|
109
|
+
|
|
110
|
+
# Only GET requests are retried (idempotent); all mutating verbs fail fast.
|
|
111
|
+
retryable = (method == :get)
|
|
112
|
+
attempt = 0
|
|
113
|
+
|
|
114
|
+
retry_after_hdr = nil
|
|
115
|
+
begin
|
|
116
|
+
attempt += 1
|
|
117
|
+
retry_after_hdr = nil # reset on each attempt
|
|
118
|
+
req = make_request(method, uri, body)
|
|
119
|
+
resp = http.request(req)
|
|
120
|
+
# Snapshot Retry-After before parse() consumes the response object,
|
|
121
|
+
# so we can honour the header even after the MelayaError is raised.
|
|
122
|
+
retry_after_hdr = resp["retry-after"] || resp["Retry-After"]
|
|
123
|
+
parse(resp)
|
|
124
|
+
rescue MelayaError => e
|
|
125
|
+
if retryable && RETRY_STATUSES.include?(e.status) && attempt <= MAX_GET_RETRIES
|
|
126
|
+
# Build a minimal resp-like object carrying only the header we need,
|
|
127
|
+
# so _backoff_delay can honour Retry-After without holding the socket.
|
|
128
|
+
hdr_carrier = { "retry-after" => retry_after_hdr }
|
|
129
|
+
delay = _backoff_delay(attempt, e, hdr_carrier)
|
|
130
|
+
sleep(delay)
|
|
131
|
+
retry
|
|
132
|
+
end
|
|
133
|
+
raise
|
|
134
|
+
rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout
|
|
135
|
+
raise unless retryable && attempt <= MAX_GET_RETRIES
|
|
136
|
+
sleep(_backoff_delay(attempt, nil))
|
|
137
|
+
retry
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Exponential backoff with ±25 % jitter; honours Retry-After on 429.
|
|
142
|
+
# Base: 2^(attempt-1) seconds, capped at 16 s before jitter.
|
|
143
|
+
#
|
|
144
|
+
# Retry-After resolution order (first match wins):
|
|
145
|
+
# 1. HTTP `Retry-After` response header — seconds integer or HTTP-date
|
|
146
|
+
# 2. JSON body `retryAfter` / `retry_after` field (legacy fallback)
|
|
147
|
+
# 3. Exponential back-off
|
|
148
|
+
def _backoff_delay(attempt, err, resp = nil)
|
|
149
|
+
if err.is_a?(MelayaError) && err.status == 429
|
|
150
|
+
# 1. HTTP Retry-After header (preferred, RFC 7231)
|
|
151
|
+
if resp.respond_to?(:[]) && (ra_hdr = resp["retry-after"] || resp["Retry-After"])
|
|
152
|
+
secs = _parse_retry_after_header(ra_hdr)
|
|
153
|
+
return [secs, 0.5].max if secs
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 2. JSON body fallback ("retryAfter" or "retry_after")
|
|
157
|
+
if err.respond_to?(:body) && err.body.is_a?(Hash)
|
|
158
|
+
ra = err.body["retryAfter"] || err.body["retry_after"]
|
|
159
|
+
return [ra.to_f, 0.5].max if ra
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
base = [2**(attempt - 1), 16].min.to_f
|
|
163
|
+
jitter = base * 0.25 * (rand - 0.5) * 2 # ±25 %
|
|
164
|
+
[base + jitter, 0.1].max
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Parse an RFC 7231 Retry-After value: either a delay-seconds integer
|
|
168
|
+
# or an HTTP-date string. Returns seconds as Float, or nil if unparseable.
|
|
169
|
+
def _parse_retry_after_header(value)
|
|
170
|
+
str = value.to_s.strip
|
|
171
|
+
# Delay-seconds: plain non-negative integer
|
|
172
|
+
if str =~ /\A\d+\z/
|
|
173
|
+
return str.to_f
|
|
174
|
+
end
|
|
175
|
+
# HTTP-date (e.g. "Wed, 21 Oct 2099 07:28:00 GMT")
|
|
176
|
+
begin
|
|
177
|
+
require "time"
|
|
178
|
+
target = Time.httpdate(str)
|
|
179
|
+
delay = target - Time.now
|
|
180
|
+
return [delay, 0.0].max
|
|
181
|
+
rescue ArgumentError, TypeError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
70
184
|
end
|
|
71
185
|
|
|
72
186
|
def parse(resp)
|
|
@@ -77,18 +191,33 @@ module Melaya
|
|
|
77
191
|
text
|
|
78
192
|
end
|
|
79
193
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
194
|
+
status = resp.code.to_i
|
|
195
|
+
if status >= 400
|
|
196
|
+
# Two error envelope shapes:
|
|
197
|
+
# 1. { error: 'tier_insufficient', tier: '...' } -> 403
|
|
198
|
+
# 2. { error: '...', message: '...', code: '...' }
|
|
199
|
+
# Extract error code safely — never echo raw body in message
|
|
200
|
+
err_code = data.is_a?(Hash) ? data["error"] : nil
|
|
201
|
+
|
|
202
|
+
if status == 403 && err_code == "tier_insufficient"
|
|
203
|
+
raise TierInsufficientError.new(tier: data.is_a?(Hash) ? data["tier"] : nil, body: data)
|
|
204
|
+
end
|
|
205
|
+
if status == 429
|
|
206
|
+
# Raised here; the GET retry loop above may swallow-and-retry it —
|
|
207
|
+
# callers only see it once retries are exhausted.
|
|
208
|
+
ra = _parse_retry_after_header(resp["retry-after"] || resp["Retry-After"])
|
|
209
|
+
raise RateLimitError.new(retry_after: ra, body: data)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
msg = "Melaya API #{resp.code}" + (err_code ? " (#{err_code})" : "")
|
|
213
|
+
raise MelayaError.new(msg, status: status, code: err_code, body: data)
|
|
84
214
|
end
|
|
85
215
|
|
|
86
|
-
# The API
|
|
87
|
-
# ok:false is a request-level failure — raise instead of returning silently.
|
|
216
|
+
# The API may wrap payload in { "ok": false, ... } for request-level failures.
|
|
88
217
|
if data.is_a?(Hash) && data["ok"] == false
|
|
89
|
-
|
|
90
|
-
msg
|
|
91
|
-
raise MelayaError.new(msg, status: resp.code.to_i, code:
|
|
218
|
+
err_code = data["error"]
|
|
219
|
+
msg = "Melaya API request failed" + (err_code ? ": #{err_code}" : "")
|
|
220
|
+
raise MelayaError.new(msg, status: resp.code.to_i, code: err_code, body: data)
|
|
92
221
|
end
|
|
93
222
|
|
|
94
223
|
data
|
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,115 @@
|
|
|
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
|
|
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
|
+
AgentsNamespace = Struct.new(
|
|
64
|
+
:pipelines,
|
|
65
|
+
:hitl,
|
|
66
|
+
:assistant,
|
|
67
|
+
:phone,
|
|
68
|
+
:evals,
|
|
69
|
+
:models,
|
|
70
|
+
keyword_init: true
|
|
71
|
+
) do
|
|
72
|
+
# +runs+ is an ergonomic alias for +pipelines+ (agents call them "runs").
|
|
73
|
+
def runs
|
|
74
|
+
pipelines
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Namespace grouping all platform-plane modules.
|
|
79
|
+
#
|
|
80
|
+
# Modules:
|
|
81
|
+
# - +projects+ — Create and list agent projects.
|
|
82
|
+
# - +credentials+ — User-scoped credential storage (services, OAuth, env handles, models).
|
|
83
|
+
# - +connectors+ — Project-scoped connector credentials.
|
|
84
|
+
# - +billing+ — Subscription, Stripe checkout/portal, pricing plans, credit balances.
|
|
85
|
+
# - +team+ — Project team management: members, roles, invite links.
|
|
86
|
+
# - +templates+ — Pipeline templates: create, share, assign, and manage visibility.
|
|
87
|
+
# - +overview+ — Pipeline overview dashboard (same object as +agents.pipelines+,
|
|
88
|
+
# exposed here for discoverability on the platform plane).
|
|
89
|
+
# - +runner+ — Runner tokens: mint, list, revoke +mel_run_+ tokens.
|
|
90
|
+
# - +auth+ — Login, MFA, registration, password management, session tokens.
|
|
91
|
+
# - +mfa+ — Alias for +auth+ (MFA operations live on the same AuthAPI object).
|
|
92
|
+
# - +accounts+ — Account management: GDPR export, CEX key removal, profile updates.
|
|
93
|
+
# - +bugs+ — Bug reports: submit, track, and comment.
|
|
94
|
+
# - +events+ — Platform real-time events over Socket.IO.
|
|
95
|
+
PlatformNamespace = Struct.new(
|
|
96
|
+
:projects,
|
|
97
|
+
:credentials,
|
|
98
|
+
:connectors,
|
|
99
|
+
:billing,
|
|
100
|
+
:team,
|
|
101
|
+
:templates,
|
|
102
|
+
:overview,
|
|
103
|
+
:runner,
|
|
104
|
+
:auth,
|
|
105
|
+
:accounts,
|
|
106
|
+
:bugs,
|
|
107
|
+
:events,
|
|
108
|
+
keyword_init: true
|
|
109
|
+
) do
|
|
110
|
+
# +mfa+ is an ergonomic alias for +auth+ (MFA methods live on AuthAPI).
|
|
111
|
+
def mfa
|
|
112
|
+
auth
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/melaya/phone.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
private
|
|
70
|
+
|
|
71
|
+
def enc(s)
|
|
72
|
+
URI.encode_www_form_component(s.to_s)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|