fragment-donor-sdk 0.1.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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +117 -0
  5. data/lib/fragment_donor_sdk.rb +355 -0
  6. metadata +107 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4e091c1109aab4095fd1f9f1b840d3323dbceac0f783134ce757b8d24f4f91d2
4
+ data.tar.gz: fd2b437c5b42b49df2ccf190ba008c7267e19f1d8601bcb0595259f353a0cbce
5
+ SHA512:
6
+ metadata.gz: 265973d1d1f4e562ac7b90ef84f429dd80bdf3339edf398e8e9865efbc3eea6f0f095beb5ee7606e30c802e5be0439f771354bb48a73ef9647c991196d1b58d1
7
+ data.tar.gz: c27b15476d7f538ab87cfad6008a9b910cde9041efd8f5e799c9dedb7314a57bd037425d376984b52e937b25119ef3e7b3717f5c041f0e5892d1589c16bcc2da
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial four-method server-side API client with typed response structs.
6
+ - Preserves exact decimal balances and unknown fields; errors redact credentials.
7
+ - Bounded optional read retry; purchase retries and redirects disabled.
8
+ - Safe structured error details and finite timeout configuration validation.
9
+ - PurchaseOutcomeUnknownError and uncertainty flag preserve ambiguous purchases.
10
+ - Syntax/whitespace gates and allowlisted installed-gem consumer verification.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fragment Donor SDK contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # Fragment Donor Ruby SDK
2
+
3
+ Independent server-side Ruby 3.2+ client. Not an official Telegram, Fragment or
4
+ TON product. Initial gem `fragment-donor-sdk` version `0.1.0`.
5
+
6
+ ## Install
7
+
8
+ After publication: `gem install fragment-donor-sdk -v 0.1.0`.
9
+ Before publication: `gem build fragment-donor-sdk.gemspec`, then
10
+ `gem install --local fragment-donor-sdk-0.1.0.gem`.
11
+
12
+ ## All four operations
13
+
14
+ ```ruby
15
+ require "fragment_donor_sdk"
16
+
17
+ client = FragmentDonor::Client.new(
18
+ credentials: FragmentDonor::Credentials.new(
19
+ mnemonic: ENV.fetch("FRAGMENT_MNEMONIC"),
20
+ cookie: ENV.fetch("FRAGMENT_COOKIE"),
21
+ wallet_version: "auto",
22
+ provider_key: ENV["TONCONSOLE_API_KEY"] # optional provider key only
23
+ ),
24
+ connect_timeout: 5,
25
+ request_timeout: 30
26
+ )
27
+ user = client.get_user_info("durov")
28
+ balance = client.wallet_balance
29
+ # Exact strings; do not convert to Float.
30
+ puts balance.ton
31
+
32
+ # Real-funds operations require both opt-in and one selected purchase.
33
+ if ENV["FRAGMENT_ALLOW_PURCHASES"] == "yes"
34
+ begin
35
+ case ENV["FRAGMENT_PURCHASE_KIND"]
36
+ when "stars" then client.buy_stars("durov", 50, payment_method: "usdt_ton")
37
+ when "premium" then client.buy_premium("durov", 3, payment_method: "ton")
38
+ end
39
+ rescue FragmentDonor::PurchaseOutcomeUnknownError => error
40
+ # Reconcile error.details["tx_hash"] manually. Do not repeat the purchase.
41
+ end
42
+ end
43
+ ```
44
+
45
+ No service account, login, `Authorization` or `X-Api-Key` is needed. Cookie and
46
+ Mnemonic are purchase credentials; balance needs only Mnemonic. `Api-Key` is
47
+ an optional TonConsole provider key. Stars: integer 50–1,000,000. Premium:
48
+ 3/6/12 months. Payment: `usdt_ton` (backend default when omitted) or `ton`.
49
+ Wallet versions: `auto`, `v5r1`, `v4r2`, `v3r2`. Wallet address, Fragment Proxy
50
+ and User-Agent are optional Credentials fields. Purchases are form-encoded.
51
+ The backend accepts GET and POST for balance; this client chooses GET.
52
+
53
+ ```ruby
54
+ begin
55
+ user = client.get_user_info("durov")
56
+ rescue FragmentDonor::RateLimitError => error
57
+ puts "Wait #{error.retry_after} seconds before your next request"
58
+ rescue FragmentDonor::UnavailableError => error
59
+ puts "Temporarily unavailable; wait #{error.retry_after} seconds"
60
+ rescue FragmentDonor::TimeoutError, FragmentDonor::NetworkError
61
+ # Purchase outcome may be unknown. Never blindly repeat a purchase.
62
+ rescue FragmentDonor::ValidationError, FragmentDonor::APIError,
63
+ FragmentDonor::MalformedResponseError => error
64
+ warn error.message # SDK messages redact configured credential values.
65
+ end
66
+ ```
67
+
68
+ Error `details` retains recursively redacted JSON fields such as `info`,
69
+ `tx_hash`, `unconfirmed`, `transient` and unknown fields for reconciliation.
70
+ An explicit purchase `unconfirmed: true` raises `PurchaseOutcomeUnknownError`
71
+ with `outcome_unknown? == true`, including HTTP 400. Timeouts/network failures,
72
+ malformed replies, redirects and ambiguous 5xx also set the uncertainty flag.
73
+ Known pre-purchase limiter codes retain RateLimitError/UnavailableError.
74
+ Reconcile `details["tx_hash"]` manually and never replay the purchase. A false
75
+ flag is not a rejection/idempotency guarantee. Remote HTTP 400/422 failures
76
+ are APIError; ValidationError is reserved for SDK preflight validation.
77
+
78
+ The shared per-IP limit is normally 30/minute. Retry hints understand HTTP
79
+ seconds, HTTP date, JSON `retry_after` and `flood_wait`. Default retry count is
80
+ zero. Opt-in `read_retries: 2, automatic_wait: true` allows read-only retries;
81
+ waits above `max_wait: 60` return immediately without retrying too early.
82
+ **Purchases always make one attempt**, even under opt-in retry configuration.
83
+ There is no backend idempotency guarantee. Redirects are not followed.
84
+ Injected transports must not retry or log credential-bearing requests.
85
+
86
+ Typed `UserInfo`, `Purchase`, `WalletBalance` preserve unknown fields in `extra`;
87
+ `ton`/`usdt_ton` are strings. Debug/JSON representations of Client/Credentials
88
+ are redacted and transport exception causes are discarded. Do not dump
89
+ arbitrary request or response internals. Use dedicated, minimally funded
90
+ wallets and server-side secret storage. The inspected backend stores submitted
91
+ credentials; do not claim zero retention.
92
+
93
+ ## Tests/build/release
94
+
95
+ From this directory in the monorepo:
96
+
97
+ ```sh
98
+ ruby scripts/check.rb
99
+ ruby -Ilib test/client_test.rb
100
+ gem build fragment-donor-sdk.gemspec
101
+ ruby smoke/verify.rb
102
+ ```
103
+
104
+ `scripts/check.rb` enforces Ruby syntax plus tabs/trailing-space/final-newline
105
+ consistency (not a full RuboCop style claim). The smoke inspects the gem's exact
106
+ file allowlist/fixture-private-key exclusion, installs the built artifact into
107
+ an isolated GEM_HOME and exercises all four methods with mocked transport,
108
+ unconfirmed Stars/Premium classification, no duplicates and redacted details.
109
+ Tests consume `../contract/fixtures.json` with synthetic credentials, mocked
110
+ transport and no real purchases. Run a secret scan and inspect gem contents
111
+ before releasing. Publish only with verified RubyGems ownership/MFA or trusted
112
+ publishing. `gem push` is a separate authorized release action; a local gem
113
+ build is not a publication. Verify installation from RubyGems after publishing.
114
+
115
+ [Docs](https://usnuz.github.io/fragment-donor-sdk/)
116
+ · [Source](https://github.com/usnuz/fragment-donor-sdk/tree/main/ruby)
117
+ · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module FragmentDonor
9
+ VERSION = "0.1.0"
10
+ DEFAULT_BASE_URL = "https://fragment.donor.uz"
11
+
12
+ class Error < StandardError
13
+ attr_reader :status, :code, :retry_after, :details
14
+
15
+ def initialize(message, status: nil, code: nil, retry_after: nil, details: nil, outcome_unknown: false)
16
+ super(message)
17
+ @status, @code, @retry_after = status, code, retry_after
18
+ @details = details
19
+ @outcome_unknown = outcome_unknown
20
+ end
21
+
22
+ # False is not a rejection or idempotency guarantee.
23
+ def outcome_unknown? = @outcome_unknown
24
+ def mark_outcome_unknown! = @outcome_unknown = true
25
+ end
26
+
27
+ class ValidationError < Error; end
28
+ class APIError < Error; end
29
+ class RateLimitError < APIError; end
30
+ class UnavailableError < APIError; end
31
+ class TimeoutError < Error; end
32
+ class NetworkError < Error; end
33
+ class MalformedResponseError < Error; end
34
+ class PurchaseOutcomeUnknownError < APIError; end
35
+
36
+ class Credentials
37
+ attr_reader :mnemonic, :cookie, :wallet_version, :wallet_address, :provider_key, :proxy, :user_agent
38
+
39
+ def initialize(mnemonic: nil, cookie: nil, wallet_version: "auto", wallet_address: nil, provider_key: nil, proxy: nil, user_agent: nil)
40
+ @mnemonic, @cookie, @wallet_version, @wallet_address = mnemonic, cookie, wallet_version, wallet_address
41
+ @provider_key, @proxy, @user_agent = provider_key, proxy, user_agent
42
+ freeze
43
+ end
44
+
45
+ def inspect = "#<FragmentDonor::Credentials [REDACTED]>"
46
+ alias to_s inspect
47
+ def to_json(*) = { credentials: "[REDACTED]" }.to_json
48
+ end
49
+
50
+ # Unknown fields remain in extra; decimal balances remain String values.
51
+ UserInfo = Struct.new(:ok, :username, :is_premium, :extra, keyword_init: true) do
52
+ def inspect = "#<FragmentDonor::UserInfo response=[REDACTED]>"
53
+ alias to_s inspect
54
+ end
55
+ Purchase = Struct.new(:ok, :data, :extra, keyword_init: true) do
56
+ def inspect = "#<FragmentDonor::Purchase response=[REDACTED]>"
57
+ alias to_s inspect
58
+ end
59
+ WalletBalance = Struct.new(:ok, :address, :ton, :usdt_ton, :extra, keyword_init: true) do
60
+ def inspect = "#<FragmentDonor::WalletBalance response=[REDACTED]>"
61
+ alias to_s inspect
62
+ end
63
+
64
+ Request = Struct.new(:method, :url, :headers, :body, keyword_init: true) do
65
+ def inspect = "#<FragmentDonor::Request #{method} [REDACTED]>"
66
+ alias to_s inspect
67
+ end
68
+ HTTPResponse = Struct.new(:status, :headers, :body, keyword_init: true)
69
+
70
+ class NetHTTPTransport
71
+ def initialize(connect_timeout:, request_timeout:)
72
+ @connect_timeout, @request_timeout = connect_timeout, request_timeout
73
+ end
74
+
75
+ def call(request)
76
+ uri = URI(request.url)
77
+ # nil disables ambient proxy discovery. Proxy header is a Fragment provider
78
+ # parameter, not a proxy for transmitting wallet credentials to this API.
79
+ http = Net::HTTP.new(uri.host, uri.port, nil)
80
+ http.use_ssl = uri.scheme == "https"
81
+ http.open_timeout = @connect_timeout
82
+ http.read_timeout = @request_timeout
83
+ http.write_timeout = @request_timeout
84
+ http.max_retries = 0 # SDK alone decides bounded GET retry; never hidden POST retry.
85
+ klass = request.method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
86
+ http_request = klass.new(uri.request_uri, request.headers)
87
+ http_request.body = request.body if request.body
88
+ response = http.request(http_request)
89
+ # Net::HTTP does not follow Location. Never implement implicit redirects.
90
+ HTTPResponse.new(status: response.code.to_i, headers: response.each_header.to_h, body: response.body.to_s)
91
+ end
92
+
93
+ def inspect = "#<FragmentDonor::NetHTTPTransport>"
94
+ end
95
+
96
+ class Client
97
+ USERNAME = /\A@?[A-Za-z][A-Za-z0-9_]{3,31}\z/
98
+ WALLET_VERSIONS = %w[auto v5r1 v4r2 v3r2].freeze
99
+ PAYMENT_METHODS = %w[usdt_ton ton].freeze
100
+
101
+ def initialize(base_url: DEFAULT_BASE_URL, credentials: Credentials.new, connect_timeout: 5, request_timeout: 30,
102
+ read_retries: 0, automatic_wait: false, max_wait: 60, transport: nil, sleeper: nil, clock: nil, allow_local_http: false)
103
+ @base = URI(base_url)
104
+ loopback = %w[localhost 127.0.0.1 ::1].include?(@base.host)
105
+ valid_scheme = @base.scheme == "https" || (allow_local_http && @base.scheme == "http" && loopback)
106
+ unless valid_scheme && @base.host && !@base.userinfo && !@base.query && !@base.fragment
107
+ raise ValidationError, "HTTPS base URL without credentials/query is required"
108
+ end
109
+ valid_timeout = ->(value) { value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.respond_to?(:positive?) && value.positive? }
110
+ unless read_retries.is_a?(Integer) && (0..2).cover?(read_retries) && valid_timeout.call(connect_timeout) && valid_timeout.call(request_timeout) && valid_timeout.call(max_wait) && max_wait <= 60
111
+ raise ValidationError, "Invalid retry/timeout configuration"
112
+ end
113
+ @credentials, @read_retries, @automatic_wait, @max_wait = credentials, read_retries, automatic_wait, max_wait
114
+ @transport = transport || NetHTTPTransport.new(connect_timeout: connect_timeout, request_timeout: request_timeout)
115
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
116
+ @clock = clock || -> { Time.now }
117
+ rescue URI::InvalidURIError
118
+ raise ValidationError.new("Invalid base URL"), cause: nil
119
+ end
120
+
121
+ def inspect = "#<FragmentDonor::Client credentials=[REDACTED]>"
122
+ alias to_s inspect
123
+ def to_json(*) = { client: "[REDACTED]" }.to_json
124
+
125
+ def get_user_info(username)
126
+ validate_username(username)
127
+ data = request("GET", "/get-user-info/", query: { username: username })
128
+ unless data["username"].nil? || data["username"].is_a?(String)
129
+ raise MalformedResponseError, "Unexpected username type"
130
+ end
131
+ if data.key?("is_premium") && ![true, false].include?(data["is_premium"])
132
+ raise MalformedResponseError, "Unexpected is_premium type"
133
+ end
134
+ UserInfo.new(ok: true, username: data["username"], is_premium: data["is_premium"], extra: extras(data, %w[ok username is_premium]))
135
+ end
136
+
137
+ def buy_stars(username, amount, payment_method: nil)
138
+ validate_username(username)
139
+ unless amount.is_a?(Integer) && (50..1_000_000).cover?(amount)
140
+ raise ValidationError, "Stars amount must be an integer from 50 to 1000000"
141
+ end
142
+ buy("/buy-stars/", username, "amount", amount, payment_method)
143
+ end
144
+
145
+ def buy_premium(username, duration, payment_method: nil)
146
+ validate_username(username)
147
+ unless duration.is_a?(Integer) && [3, 6, 12].include?(duration)
148
+ raise ValidationError, "Premium duration must be 3, 6 or 12 months"
149
+ end
150
+ buy("/buy-premium/", username, "duration", duration, payment_method)
151
+ end
152
+
153
+ # Backend accepts GET and POST; SDK deliberately uses GET.
154
+ def wallet_balance
155
+ data = request("GET", "/wallet-balance/", wallet: true)
156
+ unless %w[address ton usdt_ton].all? { |key| data[key].is_a?(String) }
157
+ raise MalformedResponseError, "Wallet address and decimal balances must be strings"
158
+ end
159
+ WalletBalance.new(ok: true, address: data["address"], ton: data["ton"], usdt_ton: data["usdt_ton"], extra: extras(data, %w[ok address ton usdt_ton]))
160
+ end
161
+
162
+ private
163
+
164
+ def validate_username(username)
165
+ raise ValidationError, "Invalid username" unless username.is_a?(String) && USERNAME.match?(username)
166
+ end
167
+
168
+ def buy(path, username, field, quantity, payment_method)
169
+ if payment_method && !PAYMENT_METHODS.include?(payment_method)
170
+ raise ValidationError, "payment_method must be usdt_ton or ton"
171
+ end
172
+ form = { "username" => username, field => quantity.to_s }
173
+ form["payment_method"] = payment_method if payment_method
174
+ data = request("POST", path, form: form, wallet: true, purchase: true)
175
+ Purchase.new(ok: true, data: data["data"], extra: extras(data, %w[ok data]))
176
+ end
177
+
178
+ def extras(data, known)
179
+ data.reject { |key, _| known.include?(key) }.freeze
180
+ end
181
+
182
+ def headers(wallet, purchase)
183
+ result = { "Accept" => "application/json" }
184
+ return result unless wallet
185
+
186
+ if @credentials.mnemonic.to_s.strip.empty?
187
+ raise ValidationError, "Mnemonic is required for wallet/purchase operations"
188
+ end
189
+ if purchase && @credentials.cookie.to_s.strip.empty?
190
+ raise ValidationError, "Cookie is required for purchases"
191
+ end
192
+ version = @credentials.wallet_version || "auto"
193
+ raise ValidationError, "Invalid Wallet-Version" unless WALLET_VERSIONS.include?(version)
194
+
195
+ result["Mnemonic"] = @credentials.mnemonic
196
+ result["Wallet-Version"] = version
197
+ { "Wallet-Address" => @credentials.wallet_address, "Api-Key" => @credentials.provider_key }.each do |key, value|
198
+ result[key] = value unless value.to_s.empty?
199
+ end
200
+ if purchase
201
+ result["Cookie"] = @credentials.cookie
202
+ { "Proxy" => @credentials.proxy, "User-Agent" => @credentials.user_agent }.each do |key, value|
203
+ result[key] = value unless value.to_s.empty?
204
+ end
205
+ end
206
+ if result.values.any? { |value| !value.is_a?(String) || value.match?(/[\r\n]/) }
207
+ raise ValidationError, "Invalid header value"
208
+ end
209
+ result
210
+ end
211
+
212
+ def request(method, path, query: nil, form: nil, wallet: false, purchase: false)
213
+ uri = @base.dup
214
+ uri.path = @base.path.delete_suffix("/") + path
215
+ uri.query = URI.encode_www_form(query) if query
216
+ request_headers = headers(wallet, purchase)
217
+ request_headers["Content-Type"] = "application/x-www-form-urlencoded" if form
218
+ request_data = Request.new(method: method, url: uri.to_s, headers: request_headers.freeze, body: form && URI.encode_www_form(form))
219
+ attempts, limit = 0, purchase ? 0 : @read_retries
220
+ loop do
221
+ begin
222
+ response = safe_transport(request_data)
223
+ return parse_response(response, purchase)
224
+ rescue Error => error
225
+ if purchase && (error.is_a?(TimeoutError) || error.is_a?(NetworkError) || error.is_a?(MalformedResponseError) ||
226
+ (error.status && error.status >= 500 && error.code != "RATE_LIMIT_UNAVAILABLE") ||
227
+ (error.status && (300..399).cover?(error.status)))
228
+ error.mark_outcome_unknown!
229
+ end
230
+ raise error, cause: nil if attempts >= limit
231
+ wait = retry_delay(error, attempts)
232
+ raise error, cause: nil unless wait
233
+ @sleeper.call(wait)
234
+ attempts += 1
235
+ end
236
+ end
237
+ end
238
+
239
+ def safe_transport(request_data)
240
+ @transport.call(request_data)
241
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error
242
+ raise TimeoutError.new("Transport timed out; sensitive details omitted"), cause: nil
243
+ rescue StandardError
244
+ raise NetworkError.new("Transport failed; sensitive details omitted"), cause: nil
245
+ end
246
+
247
+ def parse_response(response, purchase)
248
+ status = response.status
249
+ begin
250
+ data = JSON.parse(response.body)
251
+ rescue JSON::ParserError
252
+ data = nil
253
+ end
254
+ data = nil unless data.is_a?(Hash)
255
+ wait = retry_after(response.headers, data || {})
256
+ if purchase && data && data["unconfirmed"] == true
257
+ message = %w[error reason info message].filter_map { |key| data[key] if data[key].is_a?(String) && !data[key].empty? }.first
258
+ message ||= "Purchase outcome unknown; reconcile manually and do not retry"
259
+ code = data["error_code"]
260
+ code = nil unless code.is_a?(String)
261
+ raise PurchaseOutcomeUnknownError.new(redact(message), status: status, code: code && redact(code), retry_after: wait,
262
+ details: safe_details(data), outcome_unknown: true), cause: nil
263
+ end
264
+ if !(200..299).cover?(status)
265
+ klass = case status
266
+ when 429 then RateLimitError
267
+ when 503 then UnavailableError
268
+ else APIError
269
+ end
270
+ message = data && %w[error reason info message].filter_map { |key| data[key] if data[key].is_a?(String) && !data[key].empty? }.first
271
+ message = "HTTP request failed" unless message.is_a?(String)
272
+ code = data && data["error_code"]
273
+ code = nil unless code.is_a?(String)
274
+ raise klass.new(redact(message), status: status, code: code && redact(code), retry_after: wait, details: data && safe_details(data)), cause: nil
275
+ end
276
+ unless data && [true, false].include?(data["ok"])
277
+ raise MalformedResponseError.new("Expected JSON object with boolean ok", status: status), cause: nil
278
+ end
279
+ if data["ok"] == false
280
+ message = %w[error reason info].filter_map { |key| data[key] if data[key].is_a?(String) && !data[key].empty? }.first
281
+ message = "API reported failure" unless message.is_a?(String)
282
+ raise APIError.new(redact(message), status: status, retry_after: wait, details: safe_details(data)), cause: nil
283
+ end
284
+ data
285
+ end
286
+
287
+ def retry_after(headers, data)
288
+ hints = [data["retry_after"], data["flood_wait"]]
289
+ header = headers.find { |key, _| key.to_s.casecmp?("Retry-After") }&.last
290
+ if header
291
+ begin
292
+ hints << Float(header)
293
+ rescue ArgumentError, TypeError
294
+ begin
295
+ hints << Time.httpdate(header) - @clock.call
296
+ rescue ArgumentError, TypeError
297
+ # Ignore malformed retry headers; never expose raw header content.
298
+ end
299
+ end
300
+ end
301
+ parsed = hints.filter_map do |hint|
302
+ begin
303
+ number = Float(hint)
304
+ number if number.finite? && number >= 0 && number <= 31_536_000
305
+ rescue ArgumentError, TypeError
306
+ nil
307
+ end
308
+ end
309
+ parsed.max
310
+ end
311
+
312
+ def retry_delay(error, attempt)
313
+ wait = 2**attempt
314
+ if error.is_a?(RateLimitError) || error.is_a?(UnavailableError)
315
+ return nil unless @automatic_wait
316
+ wait = error.retry_after || wait
317
+ elsif !error.is_a?(TimeoutError) && !error.is_a?(NetworkError) && !(error.status && (500..599).cover?(error.status))
318
+ return nil
319
+ end
320
+ wait <= @max_wait ? wait : nil
321
+ end
322
+
323
+ def redact(message)
324
+ secrets = [@credentials.mnemonic, @credentials.mnemonic.to_s.split.join(" "), @credentials.cookie, @credentials.provider_key, @credentials.proxy]
325
+ begin
326
+ proxy = URI(@credentials.proxy.to_s)
327
+ secrets += [proxy.user, proxy.password].compact.map { |value| URI::DEFAULT_PARSER.unescape(value) }
328
+ rescue URI::InvalidURIError
329
+ # Invalid provider configuration still cannot be echoed as a raw secret.
330
+ end
331
+ @credentials.cookie.to_s.split(";").each do |part|
332
+ value = part.strip.split("=", 2)[1]
333
+ secrets += [value, value&.delete_prefix('"')&.delete_suffix('"')]
334
+ end
335
+ variants = secrets.compact.reject(&:empty?).flat_map do |value|
336
+ [value, URI::DEFAULT_PARSER.unescape(value), URI.encode_www_form_component(value)]
337
+ end
338
+ variants.reduce(message.to_s) { |text, secret| text.gsub(secret, "[REDACTED]") }
339
+ end
340
+
341
+ def safe_details(value)
342
+ case value
343
+ when Hash
344
+ sensitive = %w[mnemonic seed cookie session stringsession password proxypassword proxy apikey providerkey authorization token]
345
+ value.to_h do |key, item|
346
+ normalized = key.to_s.downcase.delete("-_")
347
+ [key, sensitive.include?(normalized) ? "[REDACTED]" : safe_details(item)]
348
+ end.freeze
349
+ when Array then value.map { |item| safe_details(item) }.freeze
350
+ when String then redact(value)
351
+ else value
352
+ end
353
+ end
354
+ end
355
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fragment-donor-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fragment Donor SDK contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.6'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.6'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
32
+ - !ruby/object:Gem::Dependency
33
+ name: net-http
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.3'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '1'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0.3'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '1'
52
+ - !ruby/object:Gem::Dependency
53
+ name: time
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0.2'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0.2'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '1'
72
+ description: Server-side Telegram Stars/Premium integration with typed responses,
73
+ exact decimal balances, safe error handling and no purchase retries.
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE
80
+ - README.md
81
+ - lib/fragment_donor_sdk.rb
82
+ homepage: https://usnuz.github.io/fragment-donor-sdk/
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ source_code_uri: https://github.com/usnuz/fragment-donor-sdk/tree/main/ruby
87
+ documentation_uri: https://usnuz.github.io/fragment-donor-sdk/
88
+ changelog_uri: https://github.com/usnuz/fragment-donor-sdk/blob/main/ruby/CHANGELOG.md
89
+ rubygems_mfa_required: 'true'
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '3.2'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.6.9
105
+ specification_version: 4
106
+ summary: Independent no-service-auth Fragment Donor API SDK
107
+ test_files: []