mixin_bot 2.6.0 → 3.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42fc9aa30981430bf8a4b32ac343994fedb94f3a318d4c519404f788f66d8c68
4
- data.tar.gz: df1037544f1eb11ec4cf196aacbd076db8a3aa6c2dc11ed1d138751cdb37e073
3
+ metadata.gz: 6c6e5421b12eabf2c32c0500c1b828228fd24a10855047f2f7f1bf605dc62ada
4
+ data.tar.gz: a18da86395abe9053e52d70965538359cbbada5bc13439196c73536382d05c87
5
5
  SHA512:
6
- metadata.gz: a12c51222b50582cd31b4e80d17f3f95c73627845ec3500701a02de21d68de887e85ccf599e505be8ec81ce6c903485ec193ae6ee3e865dce9e2bd2b984ef023
7
- data.tar.gz: 1dbb16ececb43acf6febcc42467ffb631b064fcb508f235c4a6e19148c15c328935a9ec1a297bffa45baebd70fc0b7458bf98d374fc2f5c0930734345bf67d29
6
+ metadata.gz: 4a20f4ef42062cff12ff5ecb280f8d447528ee2f7882e0425783af545b57a3fe7faecf36ca7c70387e5c3dbeb08cdcea02729b919cddadb386fe76b5b6d45fa6
7
+ data.tar.gz: f9f88a933a44502b2709fd282c8170adf73653d46721c83044358b59434444b10efac60ef82b84cb470cffaf7d4694d7bbeb270dc851c54ba5a66e40ff285713
data/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md — MixinBot
2
2
 
3
- Ruby gem (v2.3.0): Mixin Network REST SDK + `mixinbot` CLI. Parity targets: [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client), [bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client).
3
+ Ruby gem (v3.0.0): Mixin Network REST SDK + `mixinbot` CLI. Parity targets: [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client), [bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client).
4
4
 
5
5
  ## Commands
6
6
 
@@ -34,13 +34,13 @@ docs/agent/ # LLM-oriented CLI and cookbook docs
34
34
 
35
35
  ## CI and release
36
36
 
37
- - **CI** (`.github/workflows/ci.yml`): `pull_request` and `push` to `main` — `rake test` on Ruby 3.2/3.3/4.0, `rake rubocop` (3.3), `rake mixin_bot:api_coverage`.
37
+ - **CI** (`.github/workflows/ci.yml`): `pull_request` and `push` to `main` — `rake test` on Ruby 4.0, `rake rubocop` (4.0), `rake mixin_bot:api_coverage`.
38
38
  - **Release** (`.github/workflows/release.yml`): push tag `v*` (must match `MixinBot::VERSION`, e.g. tag `v2.1.0` for `VERSION = '2.1.0'`) → `rake build` → RubyGems via [trusted publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC; workflow `release.yml`, no repo secret) → GitHub Release (notes from `CHANGELOG.md`, `.gem` attached).
39
39
  - **Dependabot** (`.github/dependabot.yml`): weekly Bundler and GitHub Actions updates; Dependabot PRs use the same CI workflow.
40
40
 
41
41
  ## Conventions
42
42
 
43
- - **Ruby** >= 3.2 (CI: 3.2, 3.3, 4.0)
43
+ - **Ruby** >= 4.0 (CI: 4.0)
44
44
  - **HTTP responses**: `MixinBot::Models::ApiEnvelope` — use `res['data']` or delegated keys
45
45
  - **Safe transfers**: require `spend_key`; prefer `create_safe_transfer` / `create_transfer` (Safe pipeline)
46
46
  - **Legacy APIs**: `create_legacy_transfer`, `POST /transfers` — deprecated, warns once
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.0] - 2026-09-15
11
+
12
+ ### Added
13
+
14
+ - **Puma plugin hosting the Blaze connection** (`plugin :mixin_blaze`) — Solid Queue-style in-process hosting, removing the standalone Blaze deployment artifact. Dual run modes via the `mixin_blaze_mode` Puma DSL option: `:fork` (default) forks a dedicated Blaze child on `on_booted` with a monitor thread that stops Puma if the child dies, terminated on `on_stopped`/`on_restart`; `:async` runs the reactor on a Puma background thread in-process. Handler resolution comes from `MixinBot` configuration (resolved lazily after app boot) with an explicit ack policy (on-receipt vs after-handler); Puma 6/7 lifecycle-event compatibility; cluster mode requires `preload_app!` and fails loudly without it. Puma remains a host-app concern — no new gem dependency.
15
+ - **`MixinBot::Blaze::Reactor`** — reusable Blaze loop (connect, keepalive ping, read → handler → ack, reconnect with backoff) wrapping `API#blaze_async`; extracted from `examples/blaze_async.rb` so the plugin shell stays thin and the loop is unit-testable offline.
16
+
17
+ ### Changed
18
+
19
+ - **Breaking**: Ruby >= 4.0 is now required — `required_ruby_version` bumped from `>= 3.2.0` to `>= 4.0.0`. The CI matrix, RuboCop target, and Release workflow all run Ruby 4.0; the dev-only `parallel` version pin (kept for the old Ruby 3.2 floor) was dropped.
20
+
10
21
  ## [2.6.0] - 2026-09-07
11
22
 
12
23
  ### Added
data/README.md CHANGED
@@ -6,12 +6,12 @@ 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.6.0** (see [CHANGELOG.md](CHANGELOG.md) for breaking changes and deprecations).
9
+ Current gem version: **3.0.0** (see [CHANGELOG.md](CHANGELOG.md) for breaking changes and deprecations).
10
10
 
11
11
  ## Requirements
12
12
 
13
- - **Ruby** ≥ 3.2 (CI runs 3.2, 3.3, and 4.0).
14
- - **Bundler** 2.5+ recommended, especially on Ruby 4.
13
+ - **Ruby** ≥ 4.0 (CI runs 4.0).
14
+ - **Bundler** 2.6+ recommended.
15
15
  - Optional: the **`mixin`** CLI in `PATH` if you use `MixinBot::API#encode_raw_transaction_native` / `#decode_raw_transaction_native` or the experimental `MixinBot::NodeCLI` helpers.
16
16
 
17
17
  ## Installation
@@ -290,6 +290,43 @@ ensure
290
290
  end
291
291
  ```
292
292
 
293
+ ### Running Blaze inside Puma (`plugin :mixin_blaze`)
294
+
295
+ For Rails/Puma apps, the gem ships a Puma plugin (Solid Queue-style) that hosts the Blaze connection inside the web process tree — no standalone Blaze process to deploy or supervise:
296
+
297
+ ```ruby
298
+ # config/puma.rb
299
+ plugin :mixin_blaze
300
+ mixin_blaze_mode :fork # default; see table below
301
+ ```
302
+
303
+ ```ruby
304
+ # config/initializers/mixin_bot.rb
305
+ MixinBot.configure do
306
+ # ... credentials ...
307
+ self.blaze_handler = ->(envelope) { MyBot.process! envelope }
308
+ self.blaze_ack_policy = :on_receipt # default; or :after_handler
309
+ end
310
+ ```
311
+
312
+ The handler receives each decoded message envelope (a Hash with `action` and `data` keys) and runs serially on the hosting thread — safe for ActiveRecord. Handlers run one message at a time; the keepalive ping runs separately.
313
+
314
+ | Mode | Where the connection lives | Notes |
315
+ |---|---|---|
316
+ | `:fork` (default) | Dedicated child process forked from the Puma launcher | Crash-isolated; if the child dies, Puma is stopped so the process manager restarts the unit |
317
+ | `:async` | Background thread inside the Puma process itself | In cluster mode that process is the master; no child process |
318
+
319
+ Ack policy: `:on_receipt` acknowledges each message immediately (at-most-once dispatch — a handler crash loses that message); `:after_handler` acknowledges only after the handler completes without raising (at-least-once — handlers must be idempotent, since unacknowledged messages are redelivered on reconnect).
320
+
321
+ Receipt confirmations (`ACKNOWLEDGE_MESSAGE_RECEIPT` frames the server pushes for your own outgoing/acknowledged messages) are consumed internally: the handler never sees them and they are never re-acknowledged.
322
+
323
+ Operational notes:
324
+
325
+ - **Remove any standalone Blaze process when enabling the plugin** — two connections double your handler invocations (or split deliveries).
326
+ - **Cluster mode requires `preload_app!`** — plugins run in the launcher process, which holds no application code without preloading; the plugin reports an error and stays off otherwise.
327
+ - **Phased restarts keep the old handler code** in the hosting process until a full restart; the same applies to Solid Queue's plugin.
328
+ - Workers that need to *send* messages should use the REST API (`create_message` / `create_messages`), not the socket.
329
+
293
330
  ## Deep links and bot auth
294
331
 
295
332
  ```ruby
@@ -396,8 +433,8 @@ Examples under `examples/` expect `examples/config.yml` (copy from `examples/con
396
433
 
397
434
  GitHub Actions runs on every pull request and on pushes to `main`:
398
435
 
399
- - **Test** — Ruby 3.2, 3.3, and 4.0: `bundle exec rake test`
400
- - **RuboCop** — Ruby 3.3: `bundle exec rake rubocop`
436
+ - **Test** — Ruby 4.0: `bundle exec rake test`
437
+ - **RuboCop** — Ruby 4.0: `bundle exec rake rubocop`
401
438
  - **API coverage** — `bundle exec rake mixin_bot:api_coverage`
402
439
 
403
440
  ### Release
@@ -130,6 +130,32 @@ end
130
130
 
131
131
  See [examples/blaze.rb](../../examples/blaze.rb).
132
132
 
133
+ ## Blaze inside Puma (`plugin :mixin_blaze`)
134
+
135
+ For Rails/Puma apps the gem ships a Puma plugin (Solid Queue-style) that hosts the Blaze connection inside the web process tree — no standalone Blaze process. Requires a configured `blaze_handler`; without one the plugin logs an error and stays off.
136
+
137
+ ```ruby
138
+ # config/puma.rb
139
+ plugin :mixin_blaze
140
+ mixin_blaze_mode :fork # default (:fork = supervised child process); :async = in-process thread
141
+
142
+ # config/initializers/mixin_bot.rb
143
+ MixinBot.configure do
144
+ # ... credentials ...
145
+ self.blaze_handler = ->(envelope) { MyBot.process! envelope } # required
146
+ self.blaze_ack_policy = :on_receipt # default; :after_handler = ack after handler success
147
+ end
148
+ ```
149
+
150
+ Behavior: reconnect with bounded backoff, keepalive pings, serial handler dispatch (ActiveRecord-safe), handler exceptions logged without ending delivery, graceful shutdown with Puma (child killed on stop; in `:fork` mode a dead child stops Puma so the unit restarts). `ACKNOWLEDGE_MESSAGE_RECEIPT` confirmations are consumed internally — the handler never sees them and they are never re-acknowledged.
151
+
152
+ Operational notes:
153
+
154
+ - **Remove any standalone Blaze process when enabling the plugin** — duplicate connections duplicate handler invocations.
155
+ - **Puma cluster mode requires `preload_app!`** — plugins run in the launcher process; without preloading there is no app code to resolve the handler and the plugin reports an error.
156
+ - **Phased restarts keep old handler code** in the hosting process until a full restart.
157
+ - Workers that need to *send* messages use the REST API (`create_message`), not the socket.
158
+
133
159
  ## Discover API methods
134
160
 
135
161
  ```bash
@@ -40,10 +40,12 @@ def send_frame(connection, bytes)
40
40
  end
41
41
 
42
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 })
43
+ # endpoint_options: is forwarded to Async::HTTP::Endpoint.parse. No
44
+ # timeout: here it would become the socket's per-read timeout and kill
45
+ # quiet-but-healthy connections; liveness is the keepalive ping below.
46
+ # handler: stays default; pass an Async::WebSocket::Connection subclass to
47
+ # hook the raw frame events (e.g. PONG correlation).
48
+ connection = API.blaze_async
47
49
  p [Time.now.to_s, :connected]
48
50
 
49
51
  # liveness is the caller's policy: nothing on the wire goes stale quietly
@@ -33,7 +33,11 @@ module MixinBot
33
33
  # +handler:+ is yield-through so the caller can supply an
34
34
  # +Async::WebSocket::Connection+ subclass (e.g. one that correlates
35
35
  # PONG frames). +endpoint_options:+ are forwarded to
36
- # +Async::HTTP::Endpoint.parse+ (e.g. +timeout:+ for the connect phase).
36
+ # +Async::HTTP::Endpoint.parse+. Prefer not passing +timeout:+ there: it
37
+ # becomes the socket's per-read timeout, so a quiet-but-healthy
38
+ # connection dies when no frame arrives within that window. Bound the
39
+ # connect phase at the call site instead (see
40
+ # MixinBot::Blaze::Reactor#connect!).
37
41
  def blaze_async(handler: Async::WebSocket::Connection, endpoint_options: {})
38
42
  access_token = access_token('GET', '/', '')
39
43
  authorization = format('Bearer %<access_token>s', access_token:)
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'json'
5
+ require 'protocol/websocket'
6
+
7
+ module MixinBot
8
+ module Blaze
9
+ # Fiber-based Blaze receive loop — the hosted sibling of
10
+ # examples/blaze_async.rb.
11
+ #
12
+ # Drives API#blaze_async inside its own Async reactor (created by #run,
13
+ # so it can be called from any plain thread — e.g. a Puma plugin's
14
+ # background thread or a forked child). The loop: connect, request
15
+ # pending messages, read frames serially, dispatch each decoded envelope
16
+ # to the configured handler, and acknowledge per the ack policy. Receipt
17
+ # confirmations (ACKNOWLEDGE_MESSAGE_RECEIPT frames) are consumed
18
+ # internally — never dispatched to the handler nor re-acknowledged.
19
+ # Dropped connections are re-established with bounded exponential backoff.
20
+ #
21
+ # Handlers run serially on the reactor thread — never in concurrent
22
+ # fibers — so handler code may use per-thread resources like ActiveRecord
23
+ # connections safely. Handler exceptions are logged and do not end
24
+ # message delivery.
25
+ #
26
+ # Reactor.new(handler: ->(envelope) { ... }).run # blocks forever
27
+ #
28
+ class Reactor
29
+ CONNECT_TIMEOUT = 10
30
+ KEEPALIVE_INTERVAL = 30
31
+ INITIAL_BACKOFF = 1
32
+ MAX_BACKOFF = 30
33
+ # a connection must live at least this long for the backoff to reset
34
+ STABLE_PERIOD = 30
35
+
36
+ attr_reader :handler, :ack_policy
37
+
38
+ ##
39
+ # @param api [MixinBot::API] API used for frame codecs and connecting
40
+ # @param handler [#call] invoked with each decoded message envelope
41
+ # Hash (with +action+ and +data+ keys)
42
+ # @param ack_policy [Symbol] +:on_receipt+ (ack before the handler
43
+ # runs) or +:after_handler+ (ack only after the handler succeeds;
44
+ # failed messages are redelivered on reconnect)
45
+ # @param connection_factory [#call, nil] returns a connected
46
+ # +Async::WebSocket+ connection; defaults to +api.blaze_async+
47
+ # with a +connect_timeout+ connect phase
48
+ # @param keepalive_interval [Numeric] seconds between client pings
49
+ # @param connect_timeout [Numeric] seconds allowed for the connect phase
50
+ # (DNS + TCP + TLS + WebSocket upgrade); established connections are
51
+ # never timed out on read
52
+ # @param sleeper [#call, nil] backoff hook, called with seconds;
53
+ # defaults to +Kernel#sleep+ (inject for tests)
54
+ # @param logger [#call, nil] called with +(level, exception_or_message)+;
55
+ # defaults to +$stderr+
56
+ def initialize(handler:, api: MixinBot.api, ack_policy: MixinBot.config.blaze_ack_policy,
57
+ connection_factory: nil, keepalive_interval: KEEPALIVE_INTERVAL,
58
+ connect_timeout: CONNECT_TIMEOUT, sleeper: nil, logger: nil)
59
+ raise MixinBot::ArgumentError, 'handler must respond to #call' unless handler.respond_to?(:call)
60
+ unless %i[on_receipt after_handler].include?(ack_policy)
61
+ raise MixinBot::ArgumentError, "ack_policy must be :on_receipt or :after_handler, got #{ack_policy.inspect}"
62
+ end
63
+
64
+ @api = api
65
+ @handler = handler
66
+ @ack_policy = ack_policy
67
+ # No socket-level timeout: endpoint timeout: would apply to every read
68
+ # (a quiet-but-healthy connection would be killed), not just the
69
+ # connect phase. Liveness is the keepalive ping + the connect-phase
70
+ # timeout applied in #connect!.
71
+ @connection_factory = connection_factory || -> { api.blaze_async }
72
+ @keepalive_interval = keepalive_interval
73
+ @connect_timeout = connect_timeout
74
+ @sleeper = sleeper || ->(seconds) { sleep seconds }
75
+ @logger = logger || ->(level, detail) { warn "[mixin_blaze] #{level}: #{detail}" }
76
+ @guard = Mutex.new
77
+ @stopping = false
78
+ @connection = nil
79
+ end
80
+
81
+ ##
82
+ # Runs the connect/read/reconnect loop, blocking the calling thread
83
+ # until #stop. Yields control to the fiber scheduler while waiting.
84
+ #
85
+ # @return [void]
86
+ def run
87
+ Async do |task|
88
+ @task = task
89
+ backoff = INITIAL_BACKOFF
90
+
91
+ until stopped?
92
+ started_at = monotonic_time
93
+ begin
94
+ run_connection
95
+ backoff = INITIAL_BACKOFF if stable?(started_at)
96
+ rescue StandardError => e
97
+ log.call :error, e
98
+ backoff = INITIAL_BACKOFF if stable?(started_at)
99
+ end
100
+
101
+ break if stopped?
102
+
103
+ sleep_backoff backoff
104
+ backoff = [(backoff * 2), MAX_BACKOFF].min
105
+ end
106
+ end
107
+ end
108
+
109
+ ##
110
+ # Stops the loop and closes the current connection. Safe to call from
111
+ # any thread (including while #run blocks in another one) and safe to
112
+ # call more than once.
113
+ #
114
+ # @return [void]
115
+ def stop
116
+ @guard.synchronize do
117
+ @stopping = true
118
+ begin
119
+ @connection&.close
120
+ rescue StandardError
121
+ # already dead; the read loop will notice on its own
122
+ end
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def stopped?
129
+ @guard.synchronize { @stopping }
130
+ end
131
+
132
+ # One connection lifetime: connect, keepalive, pending request, read
133
+ # loop. Returns on a clean close; raises on a broken one.
134
+ def run_connection
135
+ return unless connect!
136
+
137
+ log.call :connected, "pid=#{Process.pid}"
138
+
139
+ keepalive = start_keepalive
140
+ send_frame @api.list_pending_message
141
+
142
+ while (message = @connection.read)
143
+ dispatch message
144
+ end
145
+ log.call :closed, 'clean close'
146
+ ensure
147
+ keepalive&.stop
148
+ @connection&.close
149
+ @connection = nil
150
+ end
151
+
152
+ # Establishes the connection and registers it under the guard, so a
153
+ # concurrent #stop can always find and close it. The connect phase
154
+ # (DNS + TCP + TLS + WebSocket upgrade) is bounded by #connect_timeout —
155
+ # a hung connect becomes an error the run loop retries, never a
156
+ # permanently stuck loop. Returns nil when the reactor stopped
157
+ # mid-connect; the fresh connection is closed here.
158
+ def connect!
159
+ fresh = @task.with_timeout(@connect_timeout) { @connection_factory.call }
160
+
161
+ @guard.synchronize do
162
+ if @stopping
163
+ begin
164
+ fresh&.close
165
+ rescue StandardError
166
+ nil
167
+ end
168
+ return nil
169
+ end
170
+
171
+ @connection = fresh
172
+ end
173
+
174
+ fresh
175
+ end
176
+
177
+ def start_keepalive
178
+ Async do
179
+ loop do
180
+ sleep @keepalive_interval
181
+ @connection&.send_ping
182
+ end
183
+ rescue Protocol::WebSocket::ProtocolError, IOError
184
+ # the connection is gone; the read loop is already winding down
185
+ end
186
+ end
187
+
188
+ # Serial dispatch — handlers never run in concurrent fibers.
189
+ def dispatch(message)
190
+ raw = decode(message)
191
+ return if raw.nil?
192
+
193
+ # Receipt confirmations echo a message_id the client already knows and
194
+ # are never redelivered by LIST_PENDING_MESSAGES — they are not
195
+ # dispatchable events, and acknowledging one back would only
196
+ # round-trip a no-op frame. Skip them before the ack policy sees them.
197
+ return if raw['action'] == 'ACKNOWLEDGE_MESSAGE_RECEIPT'
198
+
199
+ data = raw['data'].is_a?(Hash) ? raw['data'] : {}
200
+ message_id = data['message_id']
201
+
202
+ send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :on_receipt && message_id
203
+
204
+ handler_ok =
205
+ begin
206
+ handler.call raw
207
+ true
208
+ rescue StandardError => e
209
+ log.call :handler_error, e
210
+ false
211
+ end
212
+
213
+ send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :after_handler && handler_ok && message_id
214
+ end
215
+
216
+ def decode(message)
217
+ JSON.parse @api.ws_message(message.to_str)
218
+ rescue Zlib::Error, JSON::ParserError => e
219
+ log.call :decode_error, e
220
+ nil
221
+ end
222
+
223
+ def send_frame(bytes)
224
+ @connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
225
+ end
226
+
227
+ def sleep_backoff(seconds)
228
+ @sleeper.call seconds
229
+ end
230
+
231
+ def stable?(started_at)
232
+ monotonic_time - started_at >= STABLE_PERIOD
233
+ end
234
+
235
+ def monotonic_time
236
+ Process.clock_gettime Process::CLOCK_MONOTONIC
237
+ end
238
+
239
+ def log
240
+ @logger
241
+ end
242
+ end
243
+ end
244
+ end
@@ -59,6 +59,17 @@ module MixinBot
59
59
  ].freeze
60
60
  attr_accessor(*CONFIGURABLE_ATTRS)
61
61
 
62
+ # Acknowledgement policies for the Blaze message loop:
63
+ # - +:on_receipt+ (default): ack immediately on receipt, before the handler
64
+ # runs (at-most-once dispatch).
65
+ # - +:after_handler+: ack only after the handler completes without raising
66
+ # (at-least-once dispatch; unacked messages are redelivered on reconnect).
67
+ BLAZE_ACK_POLICIES = %i[on_receipt after_handler].freeze
68
+
69
+ DEFAULT_BLAZE_ACK_POLICY = :on_receipt
70
+
71
+ attr_reader :blaze_handler, :blaze_ack_policy
72
+
62
73
  ##
63
74
  # Initializes a new Configuration instance.
64
75
  #
@@ -96,6 +107,10 @@ module MixinBot
96
107
 
97
108
  @debug = kwargs[:debug] || false
98
109
 
110
+ @blaze_ack_policy = DEFAULT_BLAZE_ACK_POLICY
111
+ self.blaze_handler = kwargs[:blaze_handler]
112
+ self.blaze_ack_policy = kwargs[:blaze_ack_policy]
113
+
99
114
  self.session_private_key = kwargs[:session_private_key] || kwargs[:private_key]
100
115
  self.server_public_key = kwargs[:server_public_key] || kwargs[:pin_token]
101
116
  self.spend_key = kwargs[:spend_key]
@@ -203,6 +218,43 @@ module MixinBot
203
218
  end
204
219
  end
205
220
 
221
+ ##
222
+ # Sets the callable invoked for each decoded Blaze message envelope.
223
+ #
224
+ # Used by MixinBot::Blaze::Reactor (and the +plugin :mixin_blaze+ Puma
225
+ # plugin) to dispatch messages. The callable receives the full decoded
226
+ # message envelope Hash (with +action+ and +data+ keys).
227
+ #
228
+ # @param callable [#call, nil] the message handler; must respond to #call
229
+ # @raise [ArgumentError] if a non-callable, non-nil value is given
230
+ #
231
+ def blaze_handler=(callable)
232
+ unless callable.nil? || callable.respond_to?(:call)
233
+ raise ArgumentError,
234
+ "blaze_handler must respond to #call, got #{callable.inspect}"
235
+ end
236
+
237
+ @blaze_handler = callable
238
+ end
239
+
240
+ ##
241
+ # Sets the Blaze acknowledgement policy.
242
+ #
243
+ # @param policy [Symbol] +:on_receipt+ (default) or +:after_handler+
244
+ # @raise [ArgumentError] for unknown policies
245
+ #
246
+ def blaze_ack_policy=(policy)
247
+ return if policy.nil?
248
+
249
+ policy = policy.to_sym
250
+ unless BLAZE_ACK_POLICIES.include?(policy)
251
+ raise ArgumentError,
252
+ "blaze_ack_policy must be one of #{BLAZE_ACK_POLICIES.join(', ')}, got #{policy.inspect}"
253
+ end
254
+
255
+ @blaze_ack_policy = policy
256
+ end
257
+
206
258
  private
207
259
 
208
260
  def decode_key(key)
@@ -11,5 +11,5 @@ module MixinBot
11
11
  #
12
12
  # @see https://semver.org/
13
13
  #
14
- VERSION = '2.6.0'
14
+ VERSION = '3.0.0'
15
15
  end
data/lib/mixin_bot.rb CHANGED
@@ -26,6 +26,7 @@ require_relative 'mixin_bot/models'
26
26
  require_relative 'mixin_bot/session_store'
27
27
  require_relative 'mixin_bot/cache_store_adapter'
28
28
  require_relative 'mixin_bot/api'
29
+ require_relative 'mixin_bot/blaze/reactor'
29
30
  require_relative 'mixin_bot/bot_auth'
30
31
  require_relative 'mixin_bot/cli'
31
32
  require_relative 'mixin_bot/computer'
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Puma plugin hosting the Blaze connection inside the web process tree —
4
+ # the Solid Queue plugin architecture:
5
+ #
6
+ # # config/puma.rb
7
+ # plugin :mixin_blaze
8
+ # mixin_blaze_mode :fork # default; :async hosts the loop in-process
9
+ #
10
+ # # config/initializers/mixin_bot.rb
11
+ # MixinBot.configure do
12
+ # self.blaze_handler = ->(envelope) { MyBot.process! envelope }
13
+ # end
14
+ #
15
+ # Modes:
16
+ # - *fork* (default): after boot a dedicated child process runs
17
+ # {MixinBot::Blaze::Reactor}; a background thread supervises it and stops
18
+ # Puma if the child dies (fail fast — silent non-reception is worse than a
19
+ # visible restart). The child stops with Puma and monitors the master.
20
+ # - *async*: the reactor runs in a Puma background thread inside this very
21
+ # process. In cluster mode that process is the master.
22
+ #
23
+ # Requires `preload_app!` in cluster mode: plugins run in the launcher
24
+ # process, and without preloading it holds no application code to resolve
25
+ # the handler.
26
+ require 'English'
27
+ require 'mixin_bot'
28
+ require 'puma/plugin'
29
+
30
+ module Puma
31
+ class DSL
32
+ # Selects where the Blaze connection lives (see file comment).
33
+ #
34
+ # @param mode [Symbol] +:fork+ (default) or +:async+
35
+ def mixin_blaze_mode(mode = :fork)
36
+ @options[:mixin_blaze_mode] = mode.to_sym
37
+ end
38
+ end
39
+ end
40
+
41
+ Puma::Plugin.create do
42
+ def start(launcher)
43
+ @launcher = launcher
44
+ @log_writer = launcher.log_writer
45
+ @puma_pid = $PROCESS_ID
46
+ @mode = launcher.options[:mixin_blaze_mode] || :fork
47
+
48
+ unless %i[fork async].include?(@mode)
49
+ @log_writer.error "mixin_blaze: mixin_blaze_mode must be fork or async, got #{@mode.inspect}; plugin not started"
50
+ return
51
+ end
52
+
53
+ if launcher.options[:workers].to_i.positive? && !launcher.options[:preload_app]
54
+ @log_writer.error 'mixin_blaze: Puma cluster mode requires preload_app! (the launcher process hosts the Blaze handler); ' \
55
+ 'enable preload_app! or drop to single mode; plugin not started'
56
+ return
57
+ end
58
+
59
+ @mode == :async ? start_async_mode : start_fork_mode
60
+ end
61
+
62
+ private
63
+
64
+ def start_fork_mode
65
+ in_background { monitor_blaze_fork }
66
+
67
+ register_lifecycle(
68
+ booted: -> { fork_blaze },
69
+ stopped: -> { stop_blaze_fork },
70
+ restart: -> { stop_blaze_fork }
71
+ )
72
+ end
73
+
74
+ def start_async_mode
75
+ register_lifecycle(
76
+ booted: -> { start_blaze_async },
77
+ stopped: -> { stop_blaze_async },
78
+ restart: lambda {
79
+ stop_blaze_async
80
+ start_blaze_async
81
+ }
82
+ )
83
+ end
84
+
85
+ # Puma 7 renamed the lifecycle hooks; the old names remain as deprecated
86
+ # aliases (same branching as Solid Queue's plugin).
87
+ def register_lifecycle(booted:, stopped:, restart:)
88
+ if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new('7')
89
+ @launcher.events.on_booted { booted.call }
90
+ @launcher.events.on_stopped { stopped.call }
91
+ @launcher.events.on_restart { restart.call }
92
+ else
93
+ @launcher.events.after_booted { booted.call }
94
+ @launcher.events.after_stopped { stopped.call }
95
+ @launcher.events.before_restart { restart.call }
96
+ end
97
+ end
98
+
99
+ # ---- fork mode ----
100
+
101
+ def fork_blaze
102
+ reactor = build_reactor
103
+ return if reactor.nil?
104
+
105
+ @blaze_pid = fork do
106
+ @blaze_pid = nil
107
+ # the master's INT trap must not run here; raise Interrupt on the main
108
+ # thread instead, so the reactor unwinds and closes the connection
109
+ Signal.trap(:INT) { raise Interrupt }
110
+ Signal.trap(:TERM) { exit!(0) }
111
+ Thread.new { monitor_puma_master }
112
+
113
+ begin
114
+ reactor.run
115
+ ensure
116
+ # skip at-exit finalization, which can deadlock on inherited
117
+ # database handles (same rationale as Solid Queue's plugin)
118
+ exit!(0)
119
+ end
120
+ end
121
+
122
+ log "mixin_blaze: forked Blaze child (pid #{@blaze_pid})"
123
+ rescue NotImplementedError
124
+ @log_writer.error 'mixin_blaze: fork is unavailable on this platform; use mixin_blaze_mode :async'
125
+ end
126
+
127
+ def stop_blaze_fork
128
+ return unless @blaze_pid
129
+
130
+ @shutting_down = true
131
+
132
+ begin
133
+ Process.waitpid(@blaze_pid, Process::WNOHANG)
134
+ rescue Errno::ECHILD, Errno::ESRCH
135
+ # already gone; the reap loop below handles it
136
+ end
137
+ log 'mixin_blaze: stopping Blaze child...'
138
+ begin
139
+ Process.kill(:INT, @blaze_pid)
140
+ rescue Errno::ESRCH
141
+ @blaze_pid = nil
142
+ return
143
+ end
144
+
145
+ # bounded reap: never let a stuck child hold up Puma's shutdown
146
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10.0
147
+ reaped = false
148
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
149
+ begin
150
+ reaped = true if Process.waitpid(@blaze_pid, Process::WNOHANG)
151
+ break if reaped
152
+ rescue Errno::ECHILD, Errno::ESRCH
153
+ reaped = true
154
+ break
155
+ end
156
+ sleep 0.05
157
+ end
158
+
159
+ unless reaped
160
+ log "mixin_blaze: Blaze child #{@blaze_pid} did not stop within 10s; killing"
161
+ begin
162
+ Process.kill(:KILL, @blaze_pid)
163
+ rescue Errno::ESRCH
164
+ nil
165
+ end
166
+ begin
167
+ Process.waitpid(@blaze_pid) # KILL is fatal; reap the zombie
168
+ rescue Errno::ECHILD, Errno::ESRCH
169
+ nil
170
+ end
171
+ end
172
+ @blaze_pid = nil
173
+ rescue Errno::ECHILD, Errno::ESRCH
174
+ @blaze_pid = nil
175
+ end
176
+
177
+ def monitor_blaze_fork
178
+ loop do
179
+ if blaze_fork_dead?
180
+ log 'mixin_blaze: Blaze child has gone away, stopping Puma...'
181
+ stop_puma!
182
+ break
183
+ end
184
+ sleep 2
185
+ end
186
+ rescue StandardError => e
187
+ log "mixin_blaze: monitor thread ended (#{e.class}: #{e.message})"
188
+ end
189
+
190
+ def blaze_fork_dead?
191
+ return false if @shutting_down
192
+ return false unless @blaze_pid
193
+
194
+ Process.waitpid(@blaze_pid, Process::WNOHANG)
195
+ false
196
+ rescue Errno::ECHILD, Errno::ESRCH
197
+ true
198
+ end
199
+
200
+ def monitor_puma_master
201
+ loop do
202
+ if Process.ppid != @puma_pid
203
+ Process.kill(:INT, $PROCESS_ID)
204
+ break
205
+ end
206
+ sleep 2
207
+ end
208
+ end
209
+
210
+ # ---- async mode ----
211
+
212
+ def start_blaze_async
213
+ reactor = build_reactor
214
+ return if reactor.nil?
215
+
216
+ @reactor = reactor
217
+ @blaze_thread = Thread.new do
218
+ Thread.current.name = 'puma plugin mixin_blaze' if Thread.current.respond_to?(:name=)
219
+ reactor.run
220
+ end
221
+
222
+ log "mixin_blaze: Blaze reactor running in-process (pid #{Process.pid})"
223
+ end
224
+
225
+ def stop_blaze_async(join_limit: 1)
226
+ reactor = @reactor
227
+ thread = @blaze_thread
228
+ @reactor = nil
229
+ @blaze_thread = nil
230
+
231
+ return unless reactor
232
+
233
+ reactor.stop
234
+ thread&.join(join_limit)
235
+ log 'mixin_blaze: in-process Blaze reactor stopped'
236
+ end
237
+
238
+ # ---- shared ----
239
+
240
+ # Handler resolution happens at boot time (after the application has
241
+ # loaded — Puma starts plugin background work after load_and_bind).
242
+ def build_reactor
243
+ handler = MixinBot.config.blaze_handler
244
+ if handler.nil?
245
+ @log_writer.error 'mixin_blaze: no handler configured — set MixinBot.configure { self.blaze_handler = ->(envelope) { ... } }; ' \
246
+ 'plugin not started'
247
+ return nil
248
+ end
249
+
250
+ MixinBot::Blaze::Reactor.new(handler: handler, logger: ->(level, detail) { log("mixin_blaze #{level}: #{detail}") })
251
+ end
252
+
253
+ def stop_puma!
254
+ Process.kill(:INT, $PROCESS_ID)
255
+ end
256
+
257
+ def log(message)
258
+ @log_writer.log(message)
259
+ rescue Errno::EIO, Errno::EPIPE, Errno::EBADF
260
+ # the controlling terminal can disappear mid-shutdown; keep going
261
+ nil
262
+ end
263
+ end
data/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # MixinBot
2
2
 
3
- > Ruby SDK and CLI for Mixin Network: Safe UTXO transfers, REST API, Blaze messaging, transaction crypto, optional MVM helpers. Ruby >= 3.2. Gem version 2.3.0.
3
+ > Ruby SDK and CLI for Mixin Network: Safe UTXO transfers, REST API, Blaze messaging, transaction crypto, optional MVM helpers. Ruby >= 4.0. Gem version 3.0.0.
4
4
 
5
5
  Important notes:
6
6
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mixin_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - an-lee
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -24,6 +23,20 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '7'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
27
40
  - !ruby/object:Gem::Dependency
28
41
  name: async-websocket
29
42
  requirement: !ruby/object:Gem::Requirement
@@ -333,6 +346,7 @@ files:
333
346
  - lib/mixin_bot/api/turn.rb
334
347
  - lib/mixin_bot/api/user.rb
335
348
  - lib/mixin_bot/api/withdraw.rb
349
+ - lib/mixin_bot/blaze/reactor.rb
336
350
  - lib/mixin_bot/bot_auth.rb
337
351
  - lib/mixin_bot/cache_store_adapter.rb
338
352
  - lib/mixin_bot/cli.rb
@@ -387,6 +401,7 @@ files:
387
401
  - lib/mvm/nft.rb
388
402
  - lib/mvm/registry.rb
389
403
  - lib/mvm/scan.rb
404
+ - lib/puma/plugin/mixin_blaze.rb
390
405
  - lib/tasks/api_coverage.rake
391
406
  - llms.txt
392
407
  homepage: https://github.com/an-lee/mixin_bot
@@ -394,7 +409,6 @@ licenses:
394
409
  - MIT
395
410
  metadata:
396
411
  rubygems_mfa_required: 'true'
397
- post_install_message:
398
412
  rdoc_options: []
399
413
  require_paths:
400
414
  - lib
@@ -402,15 +416,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
402
416
  requirements:
403
417
  - - ">="
404
418
  - !ruby/object:Gem::Version
405
- version: 3.2.0
419
+ version: 4.0.0
406
420
  required_rubygems_version: !ruby/object:Gem::Requirement
407
421
  requirements:
408
422
  - - ">="
409
423
  - !ruby/object:Gem::Version
410
424
  version: '0'
411
425
  requirements: []
412
- rubygems_version: 3.5.22
413
- signing_key:
426
+ rubygems_version: 4.0.16
414
427
  specification_version: 4
415
428
  summary: A Ruby SDK for Mixin Network
416
429
  test_files: []