mixin_bot 2.4.1 → 2.6.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/API_COVERAGE.md +13 -0
- data/CHANGELOG.md +36 -0
- data/README.md +51 -2
- data/examples/blaze_async.rb +89 -0
- data/lib/mixin_bot/api/asset.rb +5 -0
- data/lib/mixin_bot/api/auth.rb +20 -14
- data/lib/mixin_bot/api/blaze.rb +4 -2
- data/lib/mixin_bot/api/blaze_async.rb +58 -0
- data/lib/mixin_bot/api/chain.rb +24 -2
- data/lib/mixin_bot/api/encrypted_message.rb +142 -5
- data/lib/mixin_bot/api/message.rb +2 -0
- data/lib/mixin_bot/api/session.rb +2 -2
- data/lib/mixin_bot/api.rb +2 -0
- data/lib/mixin_bot/cache_store_adapter.rb +49 -0
- data/lib/mixin_bot/cli/base.rb +3 -1
- data/lib/mixin_bot/client.rb +3 -2
- data/lib/mixin_bot/configuration.rb +7 -0
- data/lib/mixin_bot/errors.rb +2 -3
- data/lib/mixin_bot/session_store.rb +26 -0
- data/lib/mixin_bot/utils/amount.rb +77 -0
- data/lib/mixin_bot/utils/crypto.rb +14 -0
- data/lib/mixin_bot/utils.rb +2 -0
- data/lib/mixin_bot/version.rb +1 -1
- data/lib/mixin_bot.rb +2 -0
- metadata +21 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42fc9aa30981430bf8a4b32ac343994fedb94f3a318d4c519404f788f66d8c68
|
|
4
|
+
data.tar.gz: df1037544f1eb11ec4cf196aacbd076db8a3aa6c2dc11ed1d138751cdb37e073
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a12c51222b50582cd31b4e80d17f3f95c73627845ec3500701a02de21d68de887e85ccf599e505be8ec81ce6c903485ec193ae6ee3e865dce9e2bd2b984ef023
|
|
7
|
+
data.tar.gz: 1dbb16ececb43acf6febcc42467ffb631b064fcb508f235c4a6e19148c15c328935a9ec1a297bffa45baebd70fc0b7458bf98d374fc2f5c0930734345bf67d29
|
data/API_COVERAGE.md
CHANGED
|
@@ -41,6 +41,7 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
|
|
|
41
41
|
| AssetBalance* | `API#asset_balance` | derived from outputs | done |
|
|
42
42
|
| UserAssetBalance | `API#user_asset_balance` | GET `/safe/outputs` | done |
|
|
43
43
|
| ReadAsset | `API#network_asset` | GET `/network/assets/:id` | done |
|
|
44
|
+
| ReadSafeFees | `API#safe_fees` / `#read_safe_fees` | GET `/safe/fees` | done |
|
|
44
45
|
| ReadAssetTicker* | `API#network_ticker` | GET `/network/ticker` | done |
|
|
45
46
|
| AssetSearch | `API#network_asset_search` | GET `/network/assets/search/:q` | done |
|
|
46
47
|
| ReadNetworkAssets | `API#network_assets` | GET `/network` | done |
|
|
@@ -52,6 +53,8 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
|
|
|
52
53
|
| GetChainName | `API#chain_name` | local | done |
|
|
53
54
|
| IsChainId | `API#chain_id?` | local | done |
|
|
54
55
|
| GetFullChains | `API#full_chains` | local | done |
|
|
56
|
+
| ChainId constants (HyperEVM, X Layer, Robinhood, Pearl, corrected Sui) | `CHAIN_NAMES` + `CHAIN_STABLECOIN_ASSET_IDS` / `API#stablecoin_asset_ids` | local | done |
|
|
57
|
+
| USDT/USDC per-chain constants | `MixinBot::API::Chain::USDT_*` / `USDC_*` | local | done |
|
|
55
58
|
| **Outputs / deposits** |
|
|
56
59
|
| ListOutputs / ListUnspentOutputs | `API#safe_outputs` | GET `/safe/outputs` | done |
|
|
57
60
|
| GetOutput | `API#safe_output` | GET `/safe/outputs/:id` | done |
|
|
@@ -94,6 +97,10 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
|
|
|
94
97
|
| RotateConversation | `API#rotate_conversation` | POST `.../rotate` | done |
|
|
95
98
|
| UpdateParticipants | `API#add/remove/..._participants` | POST participants | done |
|
|
96
99
|
| PostMessage(s) | `API#send_message` | POST `/messages` | done |
|
|
100
|
+
| MessageRequest.Silent | `API#send_*(silent: true)` | POST `/messages` | done |
|
|
101
|
+
| PostEncryptedMessages | `API#post_encrypted_messages` | POST `/encrypted_messages` + one-shot retry | done |
|
|
102
|
+
| SessionStore / MapSessionStore | `MixinBot::SessionStore` | local | done |
|
|
103
|
+
| EncryptedMessageResponse / EncryptedMessageError | response envelope `data` | per-recipient `state` | done |
|
|
97
104
|
| EncryptMessageData / DecryptMessageData | `API#encrypt_message` / `#decrypt_message` | local | done |
|
|
98
105
|
| BlazeClient send helpers | `API#blaze_send_*` | WebSocket | done |
|
|
99
106
|
| **Inscriptions** |
|
|
@@ -139,6 +146,7 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
|
|
|
139
146
|
| CheckRetryableError | `MixinBot::Monitor.check_retryable_error` | local | done |
|
|
140
147
|
| **HTTP config** |
|
|
141
148
|
| Request / SetBaseUri / SetBlazeUri | `MixinBot::Configuration`, `Client` | config | n/a |
|
|
149
|
+
| SetHttpTimeout | `MixinBot.configure` `http_timeout` | config | n/a |
|
|
142
150
|
| NewSafeUser | `MixinBot::Configuration` | config | n/a |
|
|
143
151
|
| cli/*, examples/*, mixin/rpc main | `mixinbot call` / `mixinbot list` | CLI dispatch to `MixinBot::API` | done |
|
|
144
152
|
|
|
@@ -197,6 +205,8 @@ TS-only or Node-first REST surfaces. Ruby methods follow snake_case; aliases mir
|
|
|
197
205
|
| conversation.* (CRUD/participants) | `API#conversation`, `#create_*`, `#join_*`, etc. | various | done |
|
|
198
206
|
| **Message** |
|
|
199
207
|
| message.sendAcknowledgement(s) | `API#acknowledge_message` / `#acknowledge_messages` | POST `/acknowledgements` | done |
|
|
208
|
+
| message.sendLegacy | `API#send_message(hash)` | POST `/messages` singular body | alias |
|
|
209
|
+
| APP_CARD cover_url / actions | `API#blaze_send_app_card(cover_url:, actions:)` | WebSocket | done |
|
|
200
210
|
| message.sendSticker/Audio/Video/Live/Location/Transfer | `API#send_*_message` | POST `/messages` | done |
|
|
201
211
|
| message.sendText/Image/File/Post/Contact/AppCard/AppButton/Recall | `API#send_*_message` | POST `/messages` | done |
|
|
202
212
|
| **Code** |
|
|
@@ -210,6 +220,9 @@ TS-only or Node-first REST surfaces. Ruby methods follow snake_case; aliases mir
|
|
|
210
220
|
| external.deposits | `API#transactions` (legacy) | GET `/external/transactions` | done |
|
|
211
221
|
| external.checkAddress | `API#check_address` | GET `/external/addresses/check` | done |
|
|
212
222
|
| external.exchangeRates | `API#fiats` | GET `/external/fiats` | done |
|
|
223
|
+
| **Utils** |
|
|
224
|
+
| utils/amount.formatUnits / parseUnits | `MixinBot.utils.format_units` / `#parse_units` | local | done |
|
|
225
|
+
| utils/auth.getChallenge | `MixinBot.utils.oauth_code_challenge` | local | done |
|
|
213
226
|
| **Safe / UTXO / Transfer / Network / etc.** |
|
|
214
227
|
| safe.* / utxo.* / transfer.* / network.* | spread across existing `API` modules | same HTTP paths as TS | done |
|
|
215
228
|
| **Blaze** |
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.6.0] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`API#blaze_async`** — fiber-based sibling of `blaze`: returns a connected `async-websocket` client (same `wss://<blaze_host>/` URL, `Mixin-Blaze-1` subprotocol, Bearer JWT, User-Agent, and shared frame codec) for hosts driving Blaze outside EventMachine. `handler:` is yield-through for caller-supplied `Async::WebSocket::Connection` subclasses; `endpoint_options:` forwards to `Async::HTTP::Endpoint.parse`. Forces the HTTP/1 upgrade path via ALPN. Caller-side wire notes: send `write_ws_message` byte arrays as `BinaryMessage.new(ary.pack('C*'))`, feed `message.to_str` into `ws_message`, and own the keepalive ping (server pings are auto-replied).
|
|
15
|
+
|
|
16
|
+
## [2.5.0] - 2026-09-03
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Upstream SDK parity (2026 Go/Node releases)** — closes gaps with `bot-api-go-client` v3.25.3 and `bot-api-nodejs-client` v7.5.6.
|
|
21
|
+
- **`API#safe_fees`** (`read_safe_fees` alias) — `GET /safe/fees` global Safe fee schedule.
|
|
22
|
+
- **`API#post_encrypted_messages`** — encrypt-and-send pipeline (`POST /encrypted_messages`) with one-shot retry on expired recipient sessions, per-message `SUCCESS`/`FAILED` state, and a pluggable session cache.
|
|
23
|
+
- **`MixinBot::SessionStore`** — in-memory `MapSessionStore` equivalent; pass any object answering `fetch(user_id)` / `store(user_id, sessions)` as `session_store:` (storing `nil` evicts).
|
|
24
|
+
- **`MixinBot::CacheStoreAdapter`** — wraps any `ActiveSupport::Cache`-style store (`Rails.cache` with Redis/Memcached/solid_cache, or a hand-rolled read/write/delete object) so encrypted-message sessions can be cached across processes with `expires_in:` TTLs.
|
|
25
|
+
- **`MixinBot::Configuration#http_timeout`** — opt-in Faraday request timeout in seconds (nil keeps the default).
|
|
26
|
+
- **`silent:` flag** on REST `send_message` and encrypted REST sends, matching Go `MessageRequest.Silent`.
|
|
27
|
+
- **Chain registry** — HyperEVM, X Layer, Robinhood, and Pearl chain ids, plus a corrected Sui id. `CHAIN_STABLECOIN_ASSET_IDS` map and `API#stablecoin_asset_ids(chain_id)`; per-chain `USDT_*` / `USDC_*` constants.
|
|
28
|
+
- **`MixinBot.utils.format_units` / `#parse_units`** — decimal-safe unit conversion (Node `utils/amount` parity, no `Kernel#Integer` octal/hex surprises).
|
|
29
|
+
- **`MixinBot.utils.oauth_code_challenge`** — PKCE S256 code challenge (RFC 7636).
|
|
30
|
+
- **OAuth PKCE support** — `oauth_token(code, code_verifier:)` and `authorize_code(scope:, code_verifier:)` exchange and emit PKCE authorizations.
|
|
31
|
+
- **`blaze_send_app_card(cover_url:, actions:)`** — APP_CARD parity with the Node SDK.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **`API#post_encrypted_messages`** — encrypted-message checksum now reuses `MixinBot.utils.generate_user_checksum` (the dead MD5 line in `encrypt_message` is gone).
|
|
36
|
+
- **Encrypted-message session store** — default store lives on the API instance so caching persists across calls per process; expired sessions are evicted via `store(id, nil)` (Go `store.Delete` parity).
|
|
37
|
+
- **`MixinBot::Utils` CLI introspection** — `mixinbot utils list` / `utils call` now surface methods extended from submodules (`Address`, `Amount`, `Crypto`, …).
|
|
38
|
+
- **`API::Auth`** — `authorize_code` accepts `String` scopes; `REFRESH_OAUTH_CODE` payload is built from an `oauth_code_params` helper; the dead `@_app_id` ivar is gone.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **`API#post_encrypted_messages`** — non-String `data` is JSON-encoded (was Ruby `inspect` output); the `silent` flag is honored on all encrypted REST sends; sessions stores that raise or return non-arrays are tolerated (Go `err == nil` semantics); symbol-keyed sessions are normalized; per-message responses are merged across the retry so every message's final state is returned; duplicate `message_id`s are rejected; malformed `/encrypted_messages` payloads raise meaningful errors; kwargs are strictly `access_token` / `exp_in` / `scp`.
|
|
43
|
+
- **`MixinBot::Client`** — `Configuration#api_host` and `#debug` are now applied correctly even when the client is initialized with a different `config` (no more stale Faraday URL when reassigning config).
|
|
44
|
+
- **Encrypted-message test harness** — shared guarded WebMock setup in `test_helper.rb` so `LIVE=1` runs and per-file `setup` methods reset state consistently; encrypted-message stub helpers extracted for reuse.
|
|
45
|
+
|
|
10
46
|
## [2.4.1] - 2026-08-08
|
|
11
47
|
|
|
12
48
|
### Fixed
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Ruby SDK and CLI for [Mixin Network](https://developers.mixin.one/docs): authent
|
|
|
6
6
|
|
|
7
7
|
The gem aims for **parity with the official [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client)** Go SDK and **[bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client)** Node SDK. See [API_COVERAGE.md](API_COVERAGE.md) for the full mapping; run `rake mixin_bot:api_coverage` to confirm no gaps are marked missing.
|
|
8
8
|
|
|
9
|
-
Current gem version: **2.
|
|
9
|
+
Current gem version: **2.6.0** (see [CHANGELOG.md](CHANGELOG.md) for breaking changes and deprecations).
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
@@ -55,6 +55,7 @@ MixinBot.configure do
|
|
|
55
55
|
# self.pin = self.spend_key # optional; used where PIN material is required
|
|
56
56
|
# self.api_host = 'api.mixin.one'
|
|
57
57
|
# self.blaze_host = 'blaze.mixin.one'
|
|
58
|
+
# self.http_timeout = 30 # request timeout in seconds (nil = no timeout)
|
|
58
59
|
# self.debug = true # Faraday response logging
|
|
59
60
|
end
|
|
60
61
|
```
|
|
@@ -162,7 +163,7 @@ Many convenience methods on `MixinBot::API` still return the **inner `data` hash
|
|
|
162
163
|
| **Withdraw** | `withdrawals`, `create_withdraw_address`, `check_address`, `withdraw_addresses` |
|
|
163
164
|
| **Conversation** | `conversation`, `create_group_conversation`, `join_conversation`, … |
|
|
164
165
|
| **Message** | `send_message`, `send_plain_messages`, Blaze helpers |
|
|
165
|
-
| **EncryptedMessage** | `send_encrypted_*`, `encrypt_message`, `decrypt_message` |
|
|
166
|
+
| **EncryptedMessage** | `send_encrypted_*`, `post_encrypted_messages`, `encrypt_message`, `decrypt_message` |
|
|
166
167
|
| **Blaze** | `blaze`, `start_blaze_connect`, `blaze_send_plain_text`, … |
|
|
167
168
|
| **Attachment** | `create_attachment`, `upload_attachment` |
|
|
168
169
|
| **Auth** | `oauth_token`, `authorize_code`, `access_token`, `sign_oauth_access_token` |
|
|
@@ -175,6 +176,21 @@ Many convenience methods on `MixinBot::API` still return the **inner `data` hash
|
|
|
175
176
|
|
|
176
177
|
Top-level helpers on **`MixinBot::API`**: `access_token`, `encode_raw_transaction`, `decode_raw_transaction`, native variants via `mixin` CLI.
|
|
177
178
|
|
|
179
|
+
### Session caching for encrypted messages
|
|
180
|
+
|
|
181
|
+
`post_encrypted_messages` resolves recipient sessions through a **session store**: `session_store: session_store_obj` — any object answering `fetch(user_id)` (sessions or `nil`) and `store(user_id, sessions)` (storing `nil` evicts). Without one, a per-API-instance in-memory store is used.
|
|
182
|
+
|
|
183
|
+
For Rails (or any `ActiveSupport::Cache`-style store — Redis, Memcached, solid_cache), use the built-in adapter for TTLs and cross-process caching:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
MixinBot.api.post_encrypted_messages(
|
|
187
|
+
[{ recipient_id: id, category: 'ENCRYPTED_TEXT', data: 'hello' }],
|
|
188
|
+
session_store: MixinBot::CacheStoreAdapter.new(Rails.cache, expires_in: 12.hours)
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The adapter maps `store(user_id, nil)` to `cache.delete`, so sessions the server reports as expired are evicted, refreshed, and retried automatically.
|
|
193
|
+
|
|
178
194
|
### Other libraries
|
|
179
195
|
|
|
180
196
|
| Area | Description |
|
|
@@ -241,6 +257,39 @@ end
|
|
|
241
257
|
|
|
242
258
|
For outbound messages over an open socket, use `blaze_send_plain_text`, `blaze_send_contact`, `blaze_send_app_card`, and related helpers (parity with Go `BlazeClient`).
|
|
243
259
|
|
|
260
|
+
### Without EventMachine: `blaze_async`
|
|
261
|
+
|
|
262
|
+
`blaze_async` returns the connected `async-websocket` connection instead of a `Faye::WebSocket::Client` — same URL, `Mixin-Blaze-1` subprotocol, and frame codec; the caller drives the loop in an `Async` reactor. Wire notes: wrap `write_ws_message` byte arrays in `Protocol::WebSocket::BinaryMessage`, feed `message.to_str` into `ws_message`, and own the keepalive ping (see `examples/blaze_async.rb`).
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
require 'async'
|
|
266
|
+
require 'mixin_bot'
|
|
267
|
+
|
|
268
|
+
Async do |task|
|
|
269
|
+
connection = MixinBot.api.blaze_async
|
|
270
|
+
|
|
271
|
+
task.async do # keepalive is the caller's job
|
|
272
|
+
loop do
|
|
273
|
+
sleep 30
|
|
274
|
+
connection.send_ping
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
connection.write Protocol::WebSocket::BinaryMessage.new(MixinBot.api.list_pending_message.pack('C*'))
|
|
279
|
+
while (message = connection.read)
|
|
280
|
+
raw = JSON.parse MixinBot.api.ws_message(message.to_str)
|
|
281
|
+
# data is a Hash for message envelopes, an Array in LIST_PENDING_MESSAGES replies
|
|
282
|
+
data = raw['data'].is_a?(Hash) ? raw['data'] : {}
|
|
283
|
+
next unless (message_id = data['message_id'])
|
|
284
|
+
|
|
285
|
+
bytes = MixinBot.api.acknowledge_message_receipt(message_id)
|
|
286
|
+
connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
|
|
287
|
+
end
|
|
288
|
+
ensure
|
|
289
|
+
connection&.close
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
244
293
|
## Deep links and bot auth
|
|
245
294
|
|
|
246
295
|
```ruby
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fiber-based Blaze client — the async-websocket sibling of examples/blaze.rb.
|
|
4
|
+
#
|
|
5
|
+
# Drives API#blaze_async without EventMachine: the gem returns the connected
|
|
6
|
+
# connection and the caller runs the loop inside an Async reactor. The frame
|
|
7
|
+
# codec (write_ws_message / ws_message / list_pending_message /
|
|
8
|
+
# acknowledge_message_receipt) is shared with blaze verbatim; only the wire
|
|
9
|
+
# differs:
|
|
10
|
+
# - *Send*: write_ws_message returns an Array of byte integers (Faye framed
|
|
11
|
+
# that itself) — wrap it in a Protocol::WebSocket::BinaryMessage.
|
|
12
|
+
# - *Read*: connection.read yields a Protocol::WebSocket::Message (or nil
|
|
13
|
+
# after a clean close) — feed message.to_str into ws_message.
|
|
14
|
+
# - *Keepalive*: Faye's ping: 60 becomes the caller's job; server pings are
|
|
15
|
+
# auto-replied by the protocol layer.
|
|
16
|
+
#
|
|
17
|
+
# Run: ruby examples/blaze_async.rb
|
|
18
|
+
|
|
19
|
+
require './lib/mixin_bot'
|
|
20
|
+
require 'async'
|
|
21
|
+
require 'base64'
|
|
22
|
+
require 'json'
|
|
23
|
+
require 'securerandom'
|
|
24
|
+
require 'yaml'
|
|
25
|
+
|
|
26
|
+
CONFIG = YAML.load_file("#{File.dirname __FILE__}/config.yml")
|
|
27
|
+
MixinBot.configure do
|
|
28
|
+
self.app_id = CONFIG['app_id']
|
|
29
|
+
self.client_secret = CONFIG['client_secret']
|
|
30
|
+
self.session_id = CONFIG['session_id']
|
|
31
|
+
self.server_public_key = CONFIG['server_public_key']
|
|
32
|
+
self.session_private_key = CONFIG['session_private_key']
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
API = MixinBot.api
|
|
36
|
+
|
|
37
|
+
# gzip+JSON bytes from write_ws_message -> one binary WebSocket frame
|
|
38
|
+
def send_frame(connection, bytes)
|
|
39
|
+
connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Async do |task|
|
|
43
|
+
# endpoint_options: is forwarded to Async::HTTP::Endpoint.parse (a connect
|
|
44
|
+
# timeout here). handler: stays default; pass an Async::WebSocket::Connection
|
|
45
|
+
# subclass to hook the raw frame events (e.g. PONG correlation).
|
|
46
|
+
connection = API.blaze_async(endpoint_options: { timeout: 10 })
|
|
47
|
+
p [Time.now.to_s, :connected]
|
|
48
|
+
|
|
49
|
+
# liveness is the caller's policy: nothing on the wire goes stale quietly
|
|
50
|
+
keepalive = task.async do
|
|
51
|
+
loop do
|
|
52
|
+
sleep 30
|
|
53
|
+
connection.send_ping
|
|
54
|
+
end
|
|
55
|
+
rescue Protocol::WebSocket::ProtocolError, IOError # IOError covers EOFError
|
|
56
|
+
# connection is gone; the read loop below is already winding down
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# sends nothing on open — mirror blaze and request pending messages first
|
|
60
|
+
send_frame connection, API.list_pending_message
|
|
61
|
+
|
|
62
|
+
# read returns nil after a clean close; an abrupt one raises EOFError
|
|
63
|
+
while (message = connection.read)
|
|
64
|
+
raw = JSON.parse API.ws_message(message.to_str)
|
|
65
|
+
p [Time.now.to_s, :on_message, raw&.[]('action')]
|
|
66
|
+
|
|
67
|
+
# data is a Hash for message envelopes, but an Array (the pending list)
|
|
68
|
+
# in the LIST_PENDING_MESSAGES reply
|
|
69
|
+
data = raw['data'].is_a?(Hash) ? raw['data'] : {}
|
|
70
|
+
send_frame connection, API.acknowledge_message_receipt(data['message_id']) if data['message_id']
|
|
71
|
+
|
|
72
|
+
# echo PLAIN_TEXT back to its sender over the same connection
|
|
73
|
+
next unless data['category'] == 'PLAIN_TEXT'
|
|
74
|
+
|
|
75
|
+
send_frame connection, API.write_ws_message(
|
|
76
|
+
params: {
|
|
77
|
+
conversation_id: data['conversation_id'],
|
|
78
|
+
recipient_id: data['user_id'],
|
|
79
|
+
message_id: SecureRandom.uuid,
|
|
80
|
+
category: 'PLAIN_TEXT',
|
|
81
|
+
data_base64: Base64.urlsafe_encode64("echo: #{Base64.urlsafe_decode64(data['data'])}", padding: false)
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
p [Time.now.to_s, :closed]
|
|
86
|
+
ensure
|
|
87
|
+
keepalive&.stop
|
|
88
|
+
connection&.close
|
|
89
|
+
end
|
data/lib/mixin_bot/api/asset.rb
CHANGED
|
@@ -116,6 +116,11 @@ module MixinBot
|
|
|
116
116
|
end
|
|
117
117
|
alias read_asset_fee asset_fee
|
|
118
118
|
|
|
119
|
+
def safe_fees(access_token: nil)
|
|
120
|
+
client.get '/safe/fees', access_token:
|
|
121
|
+
end
|
|
122
|
+
alias read_safe_fees safe_fees
|
|
123
|
+
|
|
119
124
|
def asset_balance(asset_id)
|
|
120
125
|
outputs = safe_outputs(asset: asset_id, state: 'unspent')
|
|
121
126
|
Array(outputs['data']).sum { |o| o['amount'].to_d }
|
data/lib/mixin_bot/api/auth.rb
CHANGED
|
@@ -17,13 +17,14 @@ module MixinBot
|
|
|
17
17
|
)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def oauth_token(code)
|
|
20
|
+
def oauth_token(code, code_verifier: nil)
|
|
21
21
|
path = '/oauth/token'
|
|
22
22
|
payload = {
|
|
23
23
|
client_id: config.app_id,
|
|
24
24
|
client_secret: config.client_secret,
|
|
25
25
|
code:
|
|
26
26
|
}
|
|
27
|
+
payload[:code_verifier] = code_verifier if code_verifier.present?
|
|
27
28
|
client.post path, **payload
|
|
28
29
|
end
|
|
29
30
|
|
|
@@ -37,9 +38,13 @@ module MixinBot
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def authorize_code(**kwargs)
|
|
41
|
+
scope = kwargs[:scope] || ['PROFILE:READ']
|
|
42
|
+
scope = scope.split if scope.is_a?(String)
|
|
43
|
+
|
|
40
44
|
data = authorization_data(
|
|
41
45
|
kwargs[:app_id],
|
|
42
|
-
|
|
46
|
+
scope,
|
|
47
|
+
kwargs[:code_verifier]
|
|
43
48
|
)
|
|
44
49
|
|
|
45
50
|
path = '/oauth/authorize'
|
|
@@ -67,21 +72,22 @@ module MixinBot
|
|
|
67
72
|
end
|
|
68
73
|
alias revoke_authorize revoke_authorization
|
|
69
74
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
# REFRESH_OAUTH_CODE params for the OAuth authorization handshake.
|
|
76
|
+
def oauth_code_params(app_id:, scope:, authorization_id: '', code_verifier: nil)
|
|
77
|
+
{
|
|
78
|
+
client_id: app_id,
|
|
79
|
+
scope:,
|
|
80
|
+
authorization_id:,
|
|
81
|
+
code_challenge: code_verifier ? MixinBot.utils.oauth_code_challenge(code_verifier) : ''
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def authorization_data(app_id, scope = ['PROFILE:READ'], code_verifier = nil)
|
|
86
|
+
@_code_params = oauth_code_params(app_id:, scope: Array(scope).join, code_verifier:)
|
|
73
87
|
EM.run do
|
|
74
88
|
start_blaze_connect do
|
|
75
89
|
def on_open(websocket, _event) # rubocop:disable Lint/NestedMethodDefinition
|
|
76
|
-
websocket.send write_ws_message(
|
|
77
|
-
action: 'REFRESH_OAUTH_CODE',
|
|
78
|
-
params: {
|
|
79
|
-
client_id: @_app_id,
|
|
80
|
-
scope: @_scope,
|
|
81
|
-
authorization_id: '',
|
|
82
|
-
code_challenge: ''
|
|
83
|
-
}
|
|
84
|
-
)
|
|
90
|
+
websocket.send write_ws_message(action: 'REFRESH_OAUTH_CODE', params: @_code_params)
|
|
85
91
|
end
|
|
86
92
|
|
|
87
93
|
def on_message(websocket, event) # rubocop:disable Lint/NestedMethodDefinition
|
data/lib/mixin_bot/api/blaze.rb
CHANGED
|
@@ -101,8 +101,10 @@ module MixinBot
|
|
|
101
101
|
)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
# mirrors the upstream APP_CARD fields
|
|
105
|
+
def blaze_send_app_card(socket, conversation_id:, recipient_id:, title:, description:, action:, icon_url:, # rubocop:disable Metrics/ParameterLists
|
|
106
|
+
cover_url: nil, actions: nil)
|
|
107
|
+
data = { title:, description:, action:, icon_url:, cover_url:, actions: }.compact.to_json
|
|
106
108
|
socket.send write_ws_message(
|
|
107
109
|
params: {
|
|
108
110
|
conversation_id:,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async/http/endpoint'
|
|
4
|
+
require 'async/http/protocol/http11'
|
|
5
|
+
require 'async/websocket/client'
|
|
6
|
+
|
|
7
|
+
module MixinBot
|
|
8
|
+
class API
|
|
9
|
+
# Fiber-based sibling of +Blaze#blaze+: returns a connected
|
|
10
|
+
# +Async::WebSocket+ client instead of a +Faye::WebSocket::Client+.
|
|
11
|
+
#
|
|
12
|
+
# Same endpoint, +Mixin-Blaze-1+ subprotocol, Bearer JWT, and User-Agent as
|
|
13
|
+
# +blaze+. The frame codec (+ws_message+, +write_ws_message+,
|
|
14
|
+
# +list_pending_message+, +acknowledge_message_receipt+) is
|
|
15
|
+
# transport-agnostic and shared verbatim.
|
|
16
|
+
#
|
|
17
|
+
# Wire differences the caller must handle:
|
|
18
|
+
# - *Send*: +write_ws_message+ returns an +Array+ of byte integers (the
|
|
19
|
+
# Faye backend framed that automatically); here the caller wraps it:
|
|
20
|
+
# +connection.write(Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*')))+.
|
|
21
|
+
# - *Read*: +connection.read+ returns a +Protocol::WebSocket::Message+;
|
|
22
|
+
# pass +message.to_str+ (the binary buffer) into +ws_message+.
|
|
23
|
+
# - *Keepalive*: Faye's +ping: 60+ becomes the caller's job (server pings
|
|
24
|
+
# are still auto-replied by the protocol layer).
|
|
25
|
+
#
|
|
26
|
+
# Sends nothing on open — the caller sends +list_pending_message+ first,
|
|
27
|
+
# mirroring +blaze+. No per-IO read timeout by default: quiet-but-healthy
|
|
28
|
+
# connections are legitimate; liveness is the caller's policy.
|
|
29
|
+
module BlazeAsync
|
|
30
|
+
# Returns the block-less +Client.connect+ connection, preserving
|
|
31
|
+
# +blaze+'s "returns a client the caller drives" contract.
|
|
32
|
+
#
|
|
33
|
+
# +handler:+ is yield-through so the caller can supply an
|
|
34
|
+
# +Async::WebSocket::Connection+ subclass (e.g. one that correlates
|
|
35
|
+
# PONG frames). +endpoint_options:+ are forwarded to
|
|
36
|
+
# +Async::HTTP::Endpoint.parse+ (e.g. +timeout:+ for the connect phase).
|
|
37
|
+
def blaze_async(handler: Async::WebSocket::Connection, endpoint_options: {})
|
|
38
|
+
access_token = access_token('GET', '/', '')
|
|
39
|
+
authorization = format('Bearer %<access_token>s', access_token:)
|
|
40
|
+
|
|
41
|
+
endpoint = Async::HTTP::Endpoint.parse(
|
|
42
|
+
format('wss://%<host>s/', host: config.blaze_host),
|
|
43
|
+
# Force the HTTP/1 upgrade path: avoids the RFC 8441 (h2 CONNECT)
|
|
44
|
+
# route, which not every gateway negotiates the same way.
|
|
45
|
+
alpn_protocols: Async::HTTP::Protocol::HTTP11.names,
|
|
46
|
+
**endpoint_options
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
Async::WebSocket::Client.connect(
|
|
50
|
+
endpoint,
|
|
51
|
+
protocols: ['Mixin-Blaze-1'],
|
|
52
|
+
headers: { 'Authorization' => authorization, 'User-Agent' => "mixin_bot/#{MixinBot::VERSION}" },
|
|
53
|
+
handler: handler
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/mixin_bot/api/chain.rb
CHANGED
|
@@ -57,13 +57,30 @@ module MixinBot
|
|
|
57
57
|
'163a2142-398d-3483-aee3-d47db8da4d10' => 'MarsChain',
|
|
58
58
|
'b12bb04a-1cea-401c-a086-0be61f544889' => 'XDC Network',
|
|
59
59
|
'd2c1c7e1-a1a9-4f88-b282-d93b0a08b42b' => 'Aptos',
|
|
60
|
-
'
|
|
61
|
-
'ef660437-d915-4e27-ad3f-632bfb6ba0ee' => 'TON'
|
|
60
|
+
'3acb25e4-6216-35c3-b1ca-87184269ee08' => 'Sui',
|
|
61
|
+
'ef660437-d915-4e27-ad3f-632bfb6ba0ee' => 'TON',
|
|
62
|
+
'36d23d9e-bf4e-3ede-a12d-26f1f1f9fd2f' => 'HyperEVM',
|
|
63
|
+
'37f5a4d1-905f-3b34-8291-c37438c7dcfc' => 'X Layer',
|
|
64
|
+
'b304e03d-d004-3102-875b-8266f8407a1a' => 'Robinhood',
|
|
65
|
+
'e1bf305c-0d49-397d-85bd-55b9eaadafba' => 'Pearl'
|
|
62
66
|
}.freeze
|
|
63
67
|
|
|
64
68
|
XIN_ASSET_ID = 'c94ac88f-4671-3976-b60a-09064f1811e8'
|
|
65
69
|
VAULTA_ASSET_ID = 'ac2b79f3-ec9c-3d87-b4ca-3e825228dda5'
|
|
66
70
|
|
|
71
|
+
# Stablecoin asset ids per chain, mirroring upstream bot-api-go-client asset.go.
|
|
72
|
+
USDT_HYPEREVM = '3782f986-a053-33ae-b6bf-460abb62ce49'
|
|
73
|
+
USDT_XLAYER = 'c4d9746a-20be-321c-baca-d378534dd4eb'
|
|
74
|
+
USDC_HYPEREVM = '1e01fede-51fa-3791-9b06-5c18801b272c'
|
|
75
|
+
USDC_XLAYER = '8d706a25-514c-3c73-9446-c25fd07d0ae2'
|
|
76
|
+
USDC_SUI = 'a0f7ad61-3b9f-30f3-a1de-cd831aec33ff'
|
|
77
|
+
|
|
78
|
+
CHAIN_STABLECOIN_ASSET_IDS = {
|
|
79
|
+
'36d23d9e-bf4e-3ede-a12d-26f1f1f9fd2f' => { usdt: USDT_HYPEREVM, usdc: USDC_HYPEREVM }, # HyperEVM
|
|
80
|
+
'37f5a4d1-905f-3b34-8291-c37438c7dcfc' => { usdt: USDT_XLAYER, usdc: USDC_XLAYER }, # X Layer
|
|
81
|
+
'3acb25e4-6216-35c3-b1ca-87184269ee08' => { usdc: USDC_SUI } # Sui
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
67
84
|
def network_chain(chain_id)
|
|
68
85
|
path = format('/network/chains/%<chain_id>s', chain_id:)
|
|
69
86
|
client.get path, access_token: ''
|
|
@@ -78,6 +95,11 @@ module MixinBot
|
|
|
78
95
|
def chain_name(chain_id)
|
|
79
96
|
CHAIN_NAMES[chain_id] || 'Not Supported Chain'
|
|
80
97
|
end
|
|
98
|
+
|
|
99
|
+
# Stablecoin asset ids for a chain id (nil for chains without a mapping).
|
|
100
|
+
def stablecoin_asset_ids(chain_id)
|
|
101
|
+
CHAIN_STABLECOIN_ASSET_IDS[chain_id]
|
|
102
|
+
end
|
|
81
103
|
alias get_chain_name chain_name
|
|
82
104
|
|
|
83
105
|
def chain_id?(chain_id)
|
|
@@ -80,8 +80,8 @@ module MixinBot
|
|
|
80
80
|
def base_encrypted_message_params(options)
|
|
81
81
|
data = options[:data].is_a?(String) ? options[:data] : options[:data].to_json
|
|
82
82
|
data_base64 = encrypt_message Base64.urlsafe_encode64(data, padding: false), options[:sessions]
|
|
83
|
-
session_ids = options[:sessions].map
|
|
84
|
-
checksum =
|
|
83
|
+
session_ids = options[:sessions].map { |s| s['session_id'] || s[:session_id] }.compact.sort
|
|
84
|
+
checksum = MixinBot.utils.generate_user_checksum(options[:sessions])
|
|
85
85
|
|
|
86
86
|
{
|
|
87
87
|
conversation_id: options[:conversation_id],
|
|
@@ -92,8 +92,9 @@ module MixinBot
|
|
|
92
92
|
message_id: options[:message_id] || SecureRandom.uuid,
|
|
93
93
|
data_base64:,
|
|
94
94
|
checksum:,
|
|
95
|
-
recipient_sessions: session_ids.map
|
|
96
|
-
silent
|
|
95
|
+
recipient_sessions: session_ids.map { |session_id| { session_id: } },
|
|
96
|
+
# Go's MessageRequest always serializes `silent`; default stays false
|
|
97
|
+
silent: (options[:silent] ? true : false)
|
|
97
98
|
}.compact
|
|
98
99
|
end
|
|
99
100
|
|
|
@@ -110,13 +111,149 @@ module MixinBot
|
|
|
110
111
|
client.post path, *payload
|
|
111
112
|
end
|
|
112
113
|
|
|
114
|
+
##
|
|
115
|
+
# Encrypt and send messages using each recipient's current sessions.
|
|
116
|
+
#
|
|
117
|
+
# Accepts a single options hash or an array of them (same shapes as
|
|
118
|
+
# +send_encrypted_*_message+). Sessions are resolved through the session
|
|
119
|
+
# store - +session_store:+, or the per-API-instance default store - and
|
|
120
|
+
# fetched via +POST /sessions/fetch+ on a cache miss. Messages the server
|
|
121
|
+
# rejects because a recipient's sessions changed have their sessions
|
|
122
|
+
# evicted, refreshed, and are retried once. The returned envelope carries
|
|
123
|
+
# every message's final +state+ (SUCCESS/FAILED).
|
|
124
|
+
#
|
|
125
|
+
# @param messages [Hash, Array<Hash>] unencrypted message options
|
|
126
|
+
# @param session_store [#fetch, #store] session cache; +store(id, nil)+ evicts
|
|
127
|
+
# @return [MixinBot::Models::ApiEnvelope] per-message responses (+state+ SUCCESS/FAILED)
|
|
128
|
+
#
|
|
129
|
+
def post_encrypted_messages(messages = nil, session_store: nil, **kwargs)
|
|
130
|
+
messages = kwargs.delete(:messages) if messages.nil? && kwargs.key?(:messages)
|
|
131
|
+
messages = [messages] unless messages.is_a? Array
|
|
132
|
+
raise ArgumentError, 'messages must not be empty' if messages.empty?
|
|
133
|
+
|
|
134
|
+
token_keys = %i[access_token exp_in scp]
|
|
135
|
+
unknown = kwargs.keys - token_keys
|
|
136
|
+
raise ArgumentError, "unsupported options: #{unknown.join(', ')}" if unknown.any?
|
|
137
|
+
|
|
138
|
+
original = messages.map { |message| message.to_h.transform_keys(&:to_sym) }
|
|
139
|
+
seen_ids = []
|
|
140
|
+
pending = original.map do |message|
|
|
141
|
+
raise ArgumentError, 'recipient_id is required' if message[:recipient_id].blank?
|
|
142
|
+
if message[:message_id] && seen_ids.include?(message[:message_id])
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"duplicate message_id #{message[:message_id]}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
message[:message_id] ||= SecureRandom.uuid
|
|
148
|
+
seen_ids << message[:message_id]
|
|
149
|
+
message
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
store = session_store || (@session_store ||= MixinBot::SessionStore.new)
|
|
153
|
+
final_results = {}
|
|
154
|
+
responses = nil
|
|
155
|
+
|
|
156
|
+
2.times do |attempt|
|
|
157
|
+
recipient_ids = pending.map { |message| message[:recipient_id] }.uniq
|
|
158
|
+
missing = recipient_ids.reject { |recipient_id| cached_encrypted_sessions(store, recipient_id).present? }
|
|
159
|
+
fetched = missing.any? ? fetch_encrypted_message_sessions!(missing, store, **kwargs) : {}
|
|
160
|
+
|
|
161
|
+
requests = pending.map do |message|
|
|
162
|
+
sessions = cached_encrypted_sessions(store, message[:recipient_id]) || fetched[message[:recipient_id]]
|
|
163
|
+
raise ArgumentError, "no sessions found for recipient #{message[:recipient_id]}" if sessions.blank?
|
|
164
|
+
|
|
165
|
+
encrypted_message_request(message, sessions)
|
|
166
|
+
end
|
|
167
|
+
responses = client.post '/encrypted_messages', *requests, **kwargs
|
|
168
|
+
|
|
169
|
+
data = responses['data']
|
|
170
|
+
data = [data] if data.is_a?(Hash)
|
|
171
|
+
raise ArgumentError, 'unexpected /encrypted_messages response format' unless data.is_a? Array
|
|
172
|
+
|
|
173
|
+
results, failures = encrypted_message_results(pending, data)
|
|
174
|
+
final_results.merge!(results)
|
|
175
|
+
break if failures.empty? || attempt.positive?
|
|
176
|
+
|
|
177
|
+
pending = failures
|
|
178
|
+
# the FAILED recipients' sessions are assumed expired: evict + refresh
|
|
179
|
+
pending.map { |message| message[:recipient_id] }.uniq.each { |recipient_id| store.store recipient_id, nil }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
responses.to_h['data'] = original.filter_map { |message| final_results[message[:message_id]] }
|
|
183
|
+
responses
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Read a recipient's sessions from the store. Stores that raise (a Redis
|
|
187
|
+
# outage) or return non-list values count as a cache miss, mirroring the
|
|
188
|
+
# Go SDK's `if sessions, err := store.Get(...); err == nil` tolerance.
|
|
189
|
+
def cached_encrypted_sessions(store, recipient_id)
|
|
190
|
+
cached = begin
|
|
191
|
+
# block form keeps 1-arity duck-typed stores (Hash included) working
|
|
192
|
+
store.fetch(recipient_id) { nil } # rubocop:disable Style/RedundantFetchBlock
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
sessions = Array(cached)
|
|
198
|
+
return nil unless sessions.all?(Hash)
|
|
199
|
+
return nil if sessions.empty?
|
|
200
|
+
|
|
201
|
+
sessions.map(&:stringify_keys)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Fetch sessions for the given recipients and write them to the store.
|
|
205
|
+
# Returns { recipient_id => sessions } for the resolved recipients.
|
|
206
|
+
def fetch_encrypted_message_sessions!(recipient_ids, store, **kwargs)
|
|
207
|
+
return {} if recipient_ids.empty?
|
|
208
|
+
|
|
209
|
+
response = fetch_user_sessions(recipient_ids, **kwargs.slice(:access_token, :exp_in, :scp))
|
|
210
|
+
grouped = {}
|
|
211
|
+
Array(response['data']).each do |session|
|
|
212
|
+
owner = session['user_id'].presence
|
|
213
|
+
owner = recipient_ids.first if owner.blank? && recipient_ids.one?
|
|
214
|
+
grouped[owner] = (grouped[owner] || []) + [session] if owner.present?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
recipient_ids.each_with_object({}) do |recipient_id, resolved|
|
|
218
|
+
sessions = grouped[recipient_id]
|
|
219
|
+
raise ArgumentError, "no sessions found for recipient #{recipient_id}" if sessions.blank?
|
|
220
|
+
|
|
221
|
+
store.store recipient_id, sessions.map(&:stringify_keys)
|
|
222
|
+
resolved[recipient_id] = sessions.map(&:stringify_keys)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def encrypted_message_request(message, sessions)
|
|
227
|
+
base_encrypted_message_params message.merge(sessions:, silent: (message[:silent] ? true : false))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Maps each pending message to its server response. Returns
|
|
231
|
+
# [results_by_message_id, failed_messages]; raises on malformed responses.
|
|
232
|
+
def encrypted_message_results(messages, responses)
|
|
233
|
+
by_message_id = responses.to_h { |response| [response['message_id'], response] }
|
|
234
|
+
|
|
235
|
+
results = {}
|
|
236
|
+
failures = []
|
|
237
|
+
messages.each do |message|
|
|
238
|
+
response = by_message_id[message[:message_id]]
|
|
239
|
+
raise ArgumentError, "encrypted message response missing for #{message[:message_id]}" if response.nil?
|
|
240
|
+
|
|
241
|
+
results[message[:message_id]] = response
|
|
242
|
+
case response['state']
|
|
243
|
+
when 'SUCCESS' then nil
|
|
244
|
+
when 'FAILED' then failures << message
|
|
245
|
+
else raise ArgumentError, "encrypted message #{message[:message_id]} returned unknown state #{response['state']}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
[results, failures]
|
|
249
|
+
end
|
|
250
|
+
|
|
113
251
|
def encrypt_message(data, sessions = [], sk: nil, pk: nil) # rubocop:disable Naming/MethodParameterName
|
|
114
252
|
raise ArgumentError, 'Wrong sessions format!' unless sessions.all?(&->(s) { s.key?('session_id') && s.key?('public_key') })
|
|
115
253
|
|
|
116
254
|
sk ||= config.session_private_key[0...32]
|
|
117
255
|
pk ||= config.session_private_key[32...]
|
|
118
256
|
|
|
119
|
-
Digest::MD5.hexdigest sessions.map(&->(s) { s['session_id'] }).sort.join
|
|
120
257
|
encrypter = OpenSSL::Cipher.new('AES-128-GCM').encrypt
|
|
121
258
|
key = encrypter.random_key
|
|
122
259
|
nounce = encrypter.random_iv
|
|
@@ -92,6 +92,8 @@ module MixinBot
|
|
|
92
92
|
status: 'SENT',
|
|
93
93
|
quote_message_id: options[:quote_message_id],
|
|
94
94
|
message_id: options[:message_id] || SecureRandom.uuid,
|
|
95
|
+
# opt-in silent delivery; key is omitted unless requested (mirrors Go omitempty)
|
|
96
|
+
silent: (options[:silent] ? true : nil),
|
|
95
97
|
data: Base64.encode64(data)
|
|
96
98
|
}.compact
|
|
97
99
|
end
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module MixinBot
|
|
4
4
|
class API
|
|
5
5
|
module Session
|
|
6
|
-
def fetch_user_sessions(user_ids, access_token: nil)
|
|
6
|
+
def fetch_user_sessions(user_ids, access_token: nil, exp_in: 600, scp: 'FULL')
|
|
7
7
|
raise ArgumentError, 'user_ids required' if user_ids.blank?
|
|
8
8
|
|
|
9
|
-
client.fetch_post_array '/sessions/fetch', Array(user_ids), access_token:
|
|
9
|
+
client.fetch_post_array '/sessions/fetch', Array(user_ids), access_token:, exp_in:, scp:
|
|
10
10
|
end
|
|
11
11
|
alias fetch_user_session fetch_user_sessions
|
|
12
12
|
end
|
data/lib/mixin_bot/api.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative 'api/asset'
|
|
|
8
8
|
require_relative 'api/attachment'
|
|
9
9
|
require_relative 'api/auth'
|
|
10
10
|
require_relative 'api/blaze'
|
|
11
|
+
require_relative 'api/blaze_async'
|
|
11
12
|
require_relative 'api/chain'
|
|
12
13
|
require_relative 'api/code'
|
|
13
14
|
require_relative 'api/circle'
|
|
@@ -338,6 +339,7 @@ module MixinBot
|
|
|
338
339
|
include MixinBot::API::Attachment
|
|
339
340
|
include MixinBot::API::Auth
|
|
340
341
|
include MixinBot::API::Blaze
|
|
342
|
+
include MixinBot::API::BlazeAsync
|
|
341
343
|
include MixinBot::API::Chain
|
|
342
344
|
include MixinBot::API::Code
|
|
343
345
|
include MixinBot::API::Circle
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
##
|
|
5
|
+
# Adapter that lets any ActiveSupport::Cache-style store (Rails.cache with a
|
|
6
|
+
# Redis/Memcached/solid_cache backend, or a hand-rolled read/write/delete
|
|
7
|
+
# object) back the encrypted-message session cache.
|
|
8
|
+
#
|
|
9
|
+
# API#post_encrypted_messages expects a store answering +fetch(user_id)+ and
|
|
10
|
+
# +store(user_id, sessions)+, where storing +nil+ evicts. ActiveSupport
|
|
11
|
+
# caches speak read/write/delete instead, so this adapter translates:
|
|
12
|
+
#
|
|
13
|
+
# session_store: MixinBot::CacheStoreAdapter.new(Rails.cache, expires_in: 12.hours)
|
|
14
|
+
#
|
|
15
|
+
# The +expires_in:+ option is passed through to +cache.write+ as a TTL; the
|
|
16
|
+
# pipeline's one-shot expiry retry covers entries that lapse between fetch
|
|
17
|
+
# and send.
|
|
18
|
+
#
|
|
19
|
+
class CacheStoreAdapter
|
|
20
|
+
KEY_PREFIX = 'mixin_bot/sessions'
|
|
21
|
+
|
|
22
|
+
def initialize(cache = nil, expires_in: nil)
|
|
23
|
+
cache ||= Rails.cache if defined?(Rails)
|
|
24
|
+
raise ArgumentError, 'a cache store is required' if cache.nil?
|
|
25
|
+
|
|
26
|
+
@cache = cache
|
|
27
|
+
@expires_in = expires_in
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the cached session list for the recipient, or nil on a miss.
|
|
31
|
+
# A block passed by the caller is intentionally ignored: the block form of
|
|
32
|
+
# Rails' fetch means read-through caching, which is not wanted here.
|
|
33
|
+
def fetch(user_id)
|
|
34
|
+
@cache.read("#{KEY_PREFIX}/#{user_id}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Writes the session list for the recipient; +nil+ evicts the entry.
|
|
38
|
+
def store(user_id, sessions)
|
|
39
|
+
key = "#{KEY_PREFIX}/#{user_id}"
|
|
40
|
+
if sessions.nil?
|
|
41
|
+
@cache.delete(key)
|
|
42
|
+
elsif @expires_in
|
|
43
|
+
@cache.write(key, sessions, expires_in: @expires_in)
|
|
44
|
+
else
|
|
45
|
+
@cache.write(key, sessions)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/mixin_bot/cli/base.rb
CHANGED
|
@@ -28,6 +28,7 @@ module MixinBot
|
|
|
28
28
|
INTERACTIVE_API_METHODS = %i[
|
|
29
29
|
start_blaze_connect
|
|
30
30
|
blaze
|
|
31
|
+
blaze_async
|
|
31
32
|
upload_attachment
|
|
32
33
|
].freeze
|
|
33
34
|
|
|
@@ -58,7 +59,8 @@ module MixinBot
|
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
def utils_callable_methods
|
|
61
|
-
|
|
62
|
+
# include methods from the extend-ed submodules (Address, Amount, Crypto, ...)
|
|
63
|
+
MixinBot::Utils.singleton_methods.sort
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def api_method_owner(method_name)
|
data/lib/mixin_bot/client.rb
CHANGED
|
@@ -14,7 +14,7 @@ module MixinBot
|
|
|
14
14
|
def initialize(config)
|
|
15
15
|
@config = config || MixinBot.config
|
|
16
16
|
@conn = Faraday.new(
|
|
17
|
-
url: "#{SERVER_SCHEME}://#{config.api_host}",
|
|
17
|
+
url: "#{SERVER_SCHEME}://#{@config.api_host}",
|
|
18
18
|
headers: {
|
|
19
19
|
'Content-Type' => 'application/json',
|
|
20
20
|
'User-Agent' => "mixin_bot/#{MixinBot::VERSION}"
|
|
@@ -23,8 +23,9 @@ module MixinBot
|
|
|
23
23
|
f.request :json
|
|
24
24
|
f.request :retry, max: 2, interval: 0.5, interval_randomness: 0.5, backoff_factor: 2,
|
|
25
25
|
exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
|
|
26
|
+
f.options.timeout = @config.http_timeout if @config.http_timeout
|
|
26
27
|
f.response :json
|
|
27
|
-
f.response :logger if config.debug
|
|
28
|
+
f.response :logger if @config.debug
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
@@ -52,6 +52,7 @@ module MixinBot
|
|
|
52
52
|
pin
|
|
53
53
|
api_host
|
|
54
54
|
blaze_host
|
|
55
|
+
http_timeout
|
|
55
56
|
session_private_key_curve25519
|
|
56
57
|
server_public_key_curve25519
|
|
57
58
|
debug
|
|
@@ -71,6 +72,7 @@ module MixinBot
|
|
|
71
72
|
# @option kwargs [String] :pin the PIN (defaults to spend_key if not provided)
|
|
72
73
|
# @option kwargs [String] :api_host ('api.mixin.one') the API host
|
|
73
74
|
# @option kwargs [String] :blaze_host ('blaze.mixin.one') the Blaze WebSocket host
|
|
75
|
+
# @option kwargs [Numeric] :http_timeout (nil) request timeout in seconds; nil keeps Faraday's default
|
|
74
76
|
# @option kwargs [Boolean] :debug (false) enable debug logging
|
|
75
77
|
#
|
|
76
78
|
# @example
|
|
@@ -87,6 +89,11 @@ module MixinBot
|
|
|
87
89
|
@session_id = kwargs[:session_id]
|
|
88
90
|
@api_host = kwargs[:api_host] || 'api.mixin.one'
|
|
89
91
|
@blaze_host = kwargs[:blaze_host] || 'blaze.mixin.one'
|
|
92
|
+
@http_timeout = kwargs[:http_timeout]
|
|
93
|
+
if @http_timeout && (@http_timeout.is_a?(Numeric) ? @http_timeout <= 0 : true)
|
|
94
|
+
raise ArgumentError, "http_timeout must be a positive number of seconds, got #{@http_timeout.inspect}"
|
|
95
|
+
end
|
|
96
|
+
|
|
90
97
|
@debug = kwargs[:debug] || false
|
|
91
98
|
|
|
92
99
|
self.session_private_key = kwargs[:session_private_key] || kwargs[:private_key]
|
data/lib/mixin_bot/errors.rb
CHANGED
|
@@ -28,8 +28,8 @@ module MixinBot
|
|
|
28
28
|
attr_reader :code, :description, :status, :http_status, :request_id, :server_time,
|
|
29
29
|
:retry_after, :extra, :path, :verb, :body
|
|
30
30
|
|
|
31
|
-
#
|
|
32
|
-
def initialize(message = nil, code: nil, description: nil, status: nil, http_status: nil,
|
|
31
|
+
# structured API error metadata
|
|
32
|
+
def initialize(message = nil, code: nil, description: nil, status: nil, http_status: nil, # rubocop:disable Metrics/ParameterLists
|
|
33
33
|
request_id: nil, server_time: nil, retry_after: nil, extra: nil,
|
|
34
34
|
path: nil, verb: nil, body: nil)
|
|
35
35
|
@code = code&.to_i
|
|
@@ -45,7 +45,6 @@ module MixinBot
|
|
|
45
45
|
@body = body
|
|
46
46
|
super(message || formatted_message)
|
|
47
47
|
end
|
|
48
|
-
# rubocop:enable Metrics/ParameterLists
|
|
49
48
|
|
|
50
49
|
def client_error?
|
|
51
50
|
c = code.to_i
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
##
|
|
5
|
+
# In-memory cache of recipient sessions for encrypted-message sending.
|
|
6
|
+
#
|
|
7
|
+
# Mirrors the Go SDK's +MapSessionStore+: +fetch+ returns the cached session
|
|
8
|
+
# list for a recipient user (or nil on a miss) and +store+ overwrites it;
|
|
9
|
+
# storing +nil+ evicts the entry (used when sessions expire mid-send).
|
|
10
|
+
# Any object answering both calls can be passed as +session_store:+ to
|
|
11
|
+
# API#post_encrypted_messages to plug in custom caching (e.g. Redis, TTLs).
|
|
12
|
+
#
|
|
13
|
+
class SessionStore
|
|
14
|
+
def initialize
|
|
15
|
+
@sessions = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(user_id)
|
|
19
|
+
@sessions[user_id]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def store(user_id, sessions)
|
|
23
|
+
@sessions[user_id] = sessions
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
|
|
5
|
+
module MixinBot
|
|
6
|
+
module Utils
|
|
7
|
+
##
|
|
8
|
+
# Decimal-safe unit conversion between human-readable amounts and decimal
|
|
9
|
+
# strings, mirroring the Node SDK's utils/amount.ts (BigNumber-based).
|
|
10
|
+
#
|
|
11
|
+
module Amount
|
|
12
|
+
DECIMAL_STRING_PATTERN = /\A[+-]?\d+(\.\d+)?\z/
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# Divide an amount by 10**decimals and return the exact decimal string.
|
|
16
|
+
# Accepts Integers and decimal strings ('7.5'); leading zeros are decimal,
|
|
17
|
+
# never octal. Mirrors BigNumber(amount).dividedBy(10**unit).
|
|
18
|
+
#
|
|
19
|
+
# MixinBot.utils.format_units(10**17, 18) # => "0.1"
|
|
20
|
+
# MixinBot.utils.format_units('010', 18) # => "0.00000000000000001"
|
|
21
|
+
# MixinBot.utils.format_units('7.5', 2) # => "0.075"
|
|
22
|
+
#
|
|
23
|
+
def format_units(amount, decimals)
|
|
24
|
+
decimals = validated_decimals(decimals)
|
|
25
|
+
value = validated_amount_string(amount)
|
|
26
|
+
|
|
27
|
+
sign = value.start_with?('-') ? '-' : ''
|
|
28
|
+
whole, fraction = value.delete_prefix('-').delete_prefix('+').split('.')
|
|
29
|
+
fraction ||= ''
|
|
30
|
+
digits = "#{whole}#{fraction}".sub(/\A0+(?=\d)/, '')
|
|
31
|
+
shift = fraction.length + decimals
|
|
32
|
+
|
|
33
|
+
formatted =
|
|
34
|
+
if shift.zero?
|
|
35
|
+
digits
|
|
36
|
+
elsif digits.size > shift
|
|
37
|
+
"#{digits[0...-shift]}.#{digits[-shift..]}"
|
|
38
|
+
else
|
|
39
|
+
"0.#{'0' * (shift - digits.size)}#{digits}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
"#{sign}#{formatted}".sub(/(\.\d*?)0+\z/, '\1').sub(/\.\z/, '')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# Multiply a decimal amount string by 10**decimals and return integer
|
|
47
|
+
# minor units. Values with more precision than +decimals+ round down
|
|
48
|
+
# (floor), matching the Node SDK; invalid strings raise +ArgumentError+.
|
|
49
|
+
#
|
|
50
|
+
# MixinBot.utils.parse_units('0.1', 18) # => 100000000000000000
|
|
51
|
+
#
|
|
52
|
+
def parse_units(amount, decimals)
|
|
53
|
+
decimals = validated_decimals(decimals)
|
|
54
|
+
value = validated_amount_string(amount)
|
|
55
|
+
|
|
56
|
+
(value.to_d * (10**decimals)).floor
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validated_amount_string(amount)
|
|
62
|
+
value = amount.to_s.strip
|
|
63
|
+
raise ArgumentError, "invalid amount #{amount.inspect}" unless value.match?(DECIMAL_STRING_PATTERN)
|
|
64
|
+
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# accepts Integers and numeric strings (base-10, so '010' is ten, never octal)
|
|
69
|
+
def validated_decimals(decimals)
|
|
70
|
+
decimals = Integer(decimals, 10) if decimals.is_a?(String) && decimals.match?(/\A[+-]?\d+\z/)
|
|
71
|
+
return decimals if decimals.is_a?(Integer) && decimals >= 0
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, "decimals must be a non-negative Integer, got #{decimals.inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -95,6 +95,20 @@ module MixinBot
|
|
|
95
95
|
JOSE::JWT.sign(jwk, jws, jwt).compact
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
##
|
|
99
|
+
# Derives an OAuth PKCE code challenge (S256) from a code verifier:
|
|
100
|
+
# base64url(SHA-256(verifier)) without padding.
|
|
101
|
+
#
|
|
102
|
+
# @param verifier [String] the PKCE code verifier
|
|
103
|
+
# @return [String] the code challenge
|
|
104
|
+
#
|
|
105
|
+
# @example
|
|
106
|
+
# MixinBot.utils.oauth_code_challenge(verifier)
|
|
107
|
+
#
|
|
108
|
+
def oauth_code_challenge(verifier)
|
|
109
|
+
Base64.urlsafe_encode64 Digest::SHA256.digest(verifier), padding: false
|
|
110
|
+
end
|
|
111
|
+
|
|
98
112
|
##
|
|
99
113
|
# Generates a new Ed25519 keypair.
|
|
100
114
|
#
|
data/lib/mixin_bot/utils.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'utils/address'
|
|
4
|
+
require_relative 'utils/amount'
|
|
4
5
|
require_relative 'utils/crypto'
|
|
5
6
|
require_relative 'utils/decoder'
|
|
6
7
|
require_relative 'utils/encoder'
|
|
@@ -53,6 +54,7 @@ module MixinBot
|
|
|
53
54
|
#
|
|
54
55
|
module Utils
|
|
55
56
|
extend MixinBot::Utils::Address
|
|
57
|
+
extend MixinBot::Utils::Amount
|
|
56
58
|
extend MixinBot::Utils::Crypto
|
|
57
59
|
extend MixinBot::Utils::Decoder
|
|
58
60
|
extend MixinBot::Utils::Encoder
|
data/lib/mixin_bot/version.rb
CHANGED
data/lib/mixin_bot.rb
CHANGED
|
@@ -23,6 +23,8 @@ require 'sha3'
|
|
|
23
23
|
require_relative 'mixin_bot/errors'
|
|
24
24
|
require_relative 'mixin_bot/address'
|
|
25
25
|
require_relative 'mixin_bot/models'
|
|
26
|
+
require_relative 'mixin_bot/session_store'
|
|
27
|
+
require_relative 'mixin_bot/cache_store_adapter'
|
|
26
28
|
require_relative 'mixin_bot/api'
|
|
27
29
|
require_relative 'mixin_bot/bot_auth'
|
|
28
30
|
require_relative 'mixin_bot/cli'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mixin_bot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- an-lee
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -24,6 +24,20 @@ dependencies:
|
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '7'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: async-websocket
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.30'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.30'
|
|
27
41
|
- !ruby/object:Gem::Dependency
|
|
28
42
|
name: awesome_print
|
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -271,6 +285,7 @@ files:
|
|
|
271
285
|
- docs/agent/cli.md
|
|
272
286
|
- docs/agent/cookbook.md
|
|
273
287
|
- examples/blaze.rb
|
|
288
|
+
- examples/blaze_async.rb
|
|
274
289
|
- examples/config.yml.example
|
|
275
290
|
- lib/mixin_bot.rb
|
|
276
291
|
- lib/mixin_bot/address.rb
|
|
@@ -281,6 +296,7 @@ files:
|
|
|
281
296
|
- lib/mixin_bot/api/attachment.rb
|
|
282
297
|
- lib/mixin_bot/api/auth.rb
|
|
283
298
|
- lib/mixin_bot/api/blaze.rb
|
|
299
|
+
- lib/mixin_bot/api/blaze_async.rb
|
|
284
300
|
- lib/mixin_bot/api/chain.rb
|
|
285
301
|
- lib/mixin_bot/api/circle.rb
|
|
286
302
|
- lib/mixin_bot/api/code.rb
|
|
@@ -318,6 +334,7 @@ files:
|
|
|
318
334
|
- lib/mixin_bot/api/user.rb
|
|
319
335
|
- lib/mixin_bot/api/withdraw.rb
|
|
320
336
|
- lib/mixin_bot/bot_auth.rb
|
|
337
|
+
- lib/mixin_bot/cache_store_adapter.rb
|
|
321
338
|
- lib/mixin_bot/cli.rb
|
|
322
339
|
- lib/mixin_bot/cli/api.rb
|
|
323
340
|
- lib/mixin_bot/cli/base.rb
|
|
@@ -345,6 +362,7 @@ files:
|
|
|
345
362
|
- lib/mixin_bot/models/user.rb
|
|
346
363
|
- lib/mixin_bot/monitor.rb
|
|
347
364
|
- lib/mixin_bot/nfo.rb
|
|
365
|
+
- lib/mixin_bot/session_store.rb
|
|
348
366
|
- lib/mixin_bot/transaction.rb
|
|
349
367
|
- lib/mixin_bot/transaction/buffer.rb
|
|
350
368
|
- lib/mixin_bot/transaction/decoder.rb
|
|
@@ -352,6 +370,7 @@ files:
|
|
|
352
370
|
- lib/mixin_bot/url_scheme.rb
|
|
353
371
|
- lib/mixin_bot/utils.rb
|
|
354
372
|
- lib/mixin_bot/utils/address.rb
|
|
373
|
+
- lib/mixin_bot/utils/amount.rb
|
|
355
374
|
- lib/mixin_bot/utils/crypto.rb
|
|
356
375
|
- lib/mixin_bot/utils/decoder.rb
|
|
357
376
|
- lib/mixin_bot/utils/encoder.rb
|