mixin_bot 2.6.0 → 3.0.2

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +3 -3
  3. data/API_COVERAGE.md +1 -0
  4. data/CHANGELOG.md +22 -1
  5. data/README.md +116 -5
  6. data/docs/agent/cookbook.md +49 -0
  7. data/examples/blaze_async.rb +6 -4
  8. data/lib/generators/mixin_bot/authentication/USAGE +29 -0
  9. data/lib/generators/mixin_bot/authentication/authentication_generator.rb +82 -0
  10. data/lib/generators/mixin_bot/authentication/templates/app/controllers/concerns/authentication.rb.tt +52 -0
  11. data/lib/generators/mixin_bot/authentication/templates/app/controllers/sessions_controller.rb.tt +24 -0
  12. data/lib/generators/mixin_bot/authentication/templates/app/models/current.rb.tt +4 -0
  13. data/lib/generators/mixin_bot/authentication/templates/app/models/session.rb.tt +3 -0
  14. data/lib/generators/mixin_bot/authentication/templates/app/models/user.rb.tt +12 -0
  15. data/lib/generators/mixin_bot/authentication/templates/app/views/sessions/new.html.erb.tt +3 -0
  16. data/lib/generators/mixin_bot/authentication/templates/config/initializers/omniauth.rb.tt +21 -0
  17. data/lib/generators/mixin_bot/blaze/USAGE +19 -0
  18. data/lib/generators/mixin_bot/blaze/blaze_generator.rb +43 -0
  19. data/lib/generators/mixin_bot/blaze/templates/app/mixin/handlers/text_handler.rb +14 -0
  20. data/lib/generators/mixin_bot/blaze/templates/config/initializers/mixin_bot_blaze.rb +23 -0
  21. data/lib/generators/mixin_bot/install/USAGE +24 -0
  22. data/lib/generators/mixin_bot/install/install_generator.rb +50 -0
  23. data/lib/generators/mixin_bot/notifications/USAGE +13 -0
  24. data/lib/generators/mixin_bot/notifications/notifications_generator.rb +49 -0
  25. data/lib/generators/mixin_bot/notifications/templates/app/notifications/payment_received_notification.rb +24 -0
  26. data/lib/generators/mixin_bot/outputs/USAGE +29 -0
  27. data/lib/generators/mixin_bot/outputs/outputs_generator.rb +50 -0
  28. data/lib/generators/mixin_bot/outputs/templates/app/mixin/processors/deposit_processor.rb +21 -0
  29. data/lib/generators/mixin_bot/outputs/templates/app/models/mixin_output.rb +8 -0
  30. data/lib/generators/mixin_bot/outputs/templates/db/migrate/create_mixin_outputs.rb.tt +26 -0
  31. data/lib/generators/mixin_bot/transfers/USAGE +37 -0
  32. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/perform_job.rb +13 -0
  33. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/reconcile_job.rb +14 -0
  34. data/lib/generators/mixin_bot/transfers/templates/app/models/mixin_transfer.rb +11 -0
  35. data/lib/generators/mixin_bot/transfers/templates/db/migrate/create_mixin_transfers.rb.tt +23 -0
  36. data/lib/generators/mixin_bot/transfers/transfers_generator.rb +67 -0
  37. data/lib/generators/mixin_bot.rb +10 -0
  38. data/lib/mixin_bot/api/app.rb +20 -0
  39. data/lib/mixin_bot/api/blaze_async.rb +5 -1
  40. data/lib/mixin_bot/api/snapshot.rb +5 -0
  41. data/lib/mixin_bot/blaze/reactor.rb +249 -0
  42. data/lib/mixin_bot/blaze/router.rb +333 -0
  43. data/lib/mixin_bot/cache_store_adapter.rb +3 -1
  44. data/lib/mixin_bot/client.rb +2 -1
  45. data/lib/mixin_bot/computer.rb +3 -1
  46. data/lib/mixin_bot/configuration.rb +52 -0
  47. data/lib/mixin_bot/json_decoder.rb +14 -0
  48. data/lib/mixin_bot/notifications/mixin_channel.rb +80 -0
  49. data/lib/mixin_bot/outputs/cursor_model.rb +33 -0
  50. data/lib/mixin_bot/outputs/envelope.rb +160 -0
  51. data/lib/mixin_bot/outputs/memory_stores.rb +97 -0
  52. data/lib/mixin_bot/outputs/poller.rb +178 -0
  53. data/lib/mixin_bot/outputs/processing_job.rb +54 -0
  54. data/lib/mixin_bot/outputs/processors.rb +124 -0
  55. data/lib/mixin_bot/outputs/receipt_model.rb +86 -0
  56. data/lib/mixin_bot/outputs.rb +42 -0
  57. data/lib/mixin_bot/rails/railtie.rb +44 -0
  58. data/lib/mixin_bot/rails/tasks/poller.rake +33 -0
  59. data/lib/mixin_bot/rails.rb +34 -0
  60. data/lib/mixin_bot/registry.rb +79 -0
  61. data/lib/mixin_bot/transfers/model.rb +112 -0
  62. data/lib/mixin_bot/transfers/performer.rb +64 -0
  63. data/lib/mixin_bot/transfers/reconcile.rb +53 -0
  64. data/lib/mixin_bot/transfers.rb +23 -0
  65. data/lib/mixin_bot/version.rb +1 -1
  66. data/lib/mixin_bot.rb +4 -0
  67. data/lib/puma/plugin/mixin_blaze.rb +263 -0
  68. data/llms.txt +1 -1
  69. metadata +69 -7
@@ -0,0 +1,249 @@
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 = 300
33
+ # A connection must live at least this long to count as "stable" and
34
+ # reset the reconnect backoff. The bar sits well above any single
35
+ # backoff step so a session that a gateway throttles at a fixed short
36
+ # lifetime (e.g. closed ~30s after every handshake) never reads as
37
+ # stable — otherwise the loop pins itself at the fastest retry rate
38
+ # and hammers the endpoint indefinitely.
39
+ STABLE_PERIOD = 120
40
+
41
+ attr_reader :handler, :ack_policy
42
+
43
+ ##
44
+ # @param api [MixinBot::API] API used for frame codecs and connecting
45
+ # @param handler [#call] invoked with each decoded message envelope
46
+ # Hash (with +action+ and +data+ keys)
47
+ # @param ack_policy [Symbol] +:on_receipt+ (ack before the handler
48
+ # runs) or +:after_handler+ (ack only after the handler succeeds;
49
+ # failed messages are redelivered on reconnect)
50
+ # @param connection_factory [#call, nil] returns a connected
51
+ # +Async::WebSocket+ connection; defaults to +api.blaze_async+
52
+ # with a +connect_timeout+ connect phase
53
+ # @param keepalive_interval [Numeric] seconds between client pings
54
+ # @param connect_timeout [Numeric] seconds allowed for the connect phase
55
+ # (DNS + TCP + TLS + WebSocket upgrade); established connections are
56
+ # never timed out on read
57
+ # @param sleeper [#call, nil] backoff hook, called with seconds;
58
+ # defaults to +Kernel#sleep+ (inject for tests)
59
+ # @param logger [#call, nil] called with +(level, exception_or_message)+;
60
+ # defaults to +$stderr+
61
+ def initialize(handler:, api: MixinBot.api, ack_policy: MixinBot.config.blaze_ack_policy,
62
+ connection_factory: nil, keepalive_interval: KEEPALIVE_INTERVAL,
63
+ connect_timeout: CONNECT_TIMEOUT, sleeper: nil, logger: nil)
64
+ raise MixinBot::ArgumentError, 'handler must respond to #call' unless handler.respond_to?(:call)
65
+ unless %i[on_receipt after_handler].include?(ack_policy)
66
+ raise MixinBot::ArgumentError, "ack_policy must be :on_receipt or :after_handler, got #{ack_policy.inspect}"
67
+ end
68
+
69
+ @api = api
70
+ @handler = handler
71
+ @ack_policy = ack_policy
72
+ # No socket-level timeout: endpoint timeout: would apply to every read
73
+ # (a quiet-but-healthy connection would be killed), not just the
74
+ # connect phase. Liveness is the keepalive ping + the connect-phase
75
+ # timeout applied in #connect!.
76
+ @connection_factory = connection_factory || -> { api.blaze_async }
77
+ @keepalive_interval = keepalive_interval
78
+ @connect_timeout = connect_timeout
79
+ @sleeper = sleeper || ->(seconds) { sleep seconds }
80
+ @logger = logger || ->(level, detail) { warn "[mixin_blaze] #{level}: #{detail}" }
81
+ @guard = Mutex.new
82
+ @stopping = false
83
+ @connection = nil
84
+ end
85
+
86
+ ##
87
+ # Runs the connect/read/reconnect loop, blocking the calling thread
88
+ # until #stop. Yields control to the fiber scheduler while waiting.
89
+ #
90
+ # @return [void]
91
+ def run
92
+ Async do |task|
93
+ @task = task
94
+ backoff = INITIAL_BACKOFF
95
+
96
+ until stopped?
97
+ started_at = monotonic_time
98
+ begin
99
+ run_connection
100
+ backoff = INITIAL_BACKOFF if stable?(started_at)
101
+ rescue StandardError => e
102
+ log.call :error, e
103
+ backoff = INITIAL_BACKOFF if stable?(started_at)
104
+ end
105
+
106
+ break if stopped?
107
+
108
+ sleep_backoff backoff
109
+ backoff = [(backoff * 2), MAX_BACKOFF].min
110
+ end
111
+ end
112
+ end
113
+
114
+ ##
115
+ # Stops the loop and closes the current connection. Safe to call from
116
+ # any thread (including while #run blocks in another one) and safe to
117
+ # call more than once.
118
+ #
119
+ # @return [void]
120
+ def stop
121
+ @guard.synchronize do
122
+ @stopping = true
123
+ begin
124
+ @connection&.close
125
+ rescue StandardError
126
+ # already dead; the read loop will notice on its own
127
+ end
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def stopped?
134
+ @guard.synchronize { @stopping }
135
+ end
136
+
137
+ # One connection lifetime: connect, keepalive, pending request, read
138
+ # loop. Returns on a clean close; raises on a broken one.
139
+ def run_connection
140
+ return unless connect!
141
+
142
+ log.call :connected, "pid=#{Process.pid}"
143
+
144
+ keepalive = start_keepalive
145
+ send_frame @api.list_pending_message
146
+
147
+ while (message = @connection.read)
148
+ dispatch message
149
+ end
150
+ log.call :closed, 'clean close'
151
+ ensure
152
+ keepalive&.stop
153
+ @connection&.close
154
+ @connection = nil
155
+ end
156
+
157
+ # Establishes the connection and registers it under the guard, so a
158
+ # concurrent #stop can always find and close it. The connect phase
159
+ # (DNS + TCP + TLS + WebSocket upgrade) is bounded by #connect_timeout —
160
+ # a hung connect becomes an error the run loop retries, never a
161
+ # permanently stuck loop. Returns nil when the reactor stopped
162
+ # mid-connect; the fresh connection is closed here.
163
+ def connect!
164
+ fresh = @task.with_timeout(@connect_timeout) { @connection_factory.call }
165
+
166
+ @guard.synchronize do
167
+ if @stopping
168
+ begin
169
+ fresh&.close
170
+ rescue StandardError
171
+ nil
172
+ end
173
+ return nil
174
+ end
175
+
176
+ @connection = fresh
177
+ end
178
+
179
+ fresh
180
+ end
181
+
182
+ def start_keepalive
183
+ Async do
184
+ loop do
185
+ sleep @keepalive_interval
186
+ @connection&.send_ping
187
+ end
188
+ rescue Protocol::WebSocket::ProtocolError, IOError
189
+ # the connection is gone; the read loop is already winding down
190
+ end
191
+ end
192
+
193
+ # Serial dispatch — handlers never run in concurrent fibers.
194
+ def dispatch(message)
195
+ raw = decode(message)
196
+ return if raw.nil?
197
+
198
+ # Receipt confirmations echo a message_id the client already knows and
199
+ # are never redelivered by LIST_PENDING_MESSAGES — they are not
200
+ # dispatchable events, and acknowledging one back would only
201
+ # round-trip a no-op frame. Skip them before the ack policy sees them.
202
+ return if raw['action'] == 'ACKNOWLEDGE_MESSAGE_RECEIPT'
203
+
204
+ data = raw['data'].is_a?(Hash) ? raw['data'] : {}
205
+ message_id = data['message_id']
206
+
207
+ send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :on_receipt && message_id
208
+
209
+ handler_ok =
210
+ begin
211
+ handler.call raw
212
+ true
213
+ rescue StandardError => e
214
+ log.call :handler_error, e
215
+ false
216
+ end
217
+
218
+ send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :after_handler && handler_ok && message_id
219
+ end
220
+
221
+ def decode(message)
222
+ JSON.parse @api.ws_message(message.to_str)
223
+ rescue Zlib::Error, JSON::ParserError => e
224
+ log.call :decode_error, e
225
+ nil
226
+ end
227
+
228
+ def send_frame(bytes)
229
+ @connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
230
+ end
231
+
232
+ def sleep_backoff(seconds)
233
+ @sleeper.call seconds
234
+ end
235
+
236
+ def stable?(started_at)
237
+ monotonic_time - started_at >= STABLE_PERIOD
238
+ end
239
+
240
+ def monotonic_time
241
+ Process.clock_gettime Process::CLOCK_MONOTONIC
242
+ end
243
+
244
+ def log
245
+ @logger
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module MixinBot
6
+ module Blaze
7
+ # Routes decoded Blaze envelopes to handler classes — the dispatch layer
8
+ # above {MixinBot::Blaze::Reactor}'s raw `blaze_handler` lambda contract.
9
+ #
10
+ # A router IS a handler: it answers #call with the decoded envelope Hash,
11
+ # so it plugs straight into the configured Blaze handler:
12
+ #
13
+ # router = MixinBot::Blaze::Router.new do
14
+ # on 'text', EchoHandler
15
+ # on 'transfer', DepositHandler
16
+ # on category: 'APP_CARD', handler: AppCardHandler
17
+ # on action: 'CREATE_MESSAGE', handler: ->(message) { ... }
18
+ # end
19
+ #
20
+ # MixinBot.configure do
21
+ # self.blaze_handler = router
22
+ # end
23
+ #
24
+ # or per bot: MixinBot::Blaze::Router.new(api: MixinBot.bot(:shop)) { ... }
25
+ #
26
+ # Matching is first-registered-wins on the message category (aliases like
27
+ # 'text' normalize to PLAIN_TEXT; raw categories pass through verbatim) or
28
+ # on a Hash of conditions ({action:, category:}) that must all hold.
29
+ # Unmatched envelopes are logged and ignored.
30
+ #
31
+ # Handler exceptions are logged (:handler_error) and re-raised: the
32
+ # reactor owns containment (it logs and keeps the loop alive) and, under
33
+ # `ack_policy: :after_handler`, withholds the acknowledgement so a failed
34
+ # message is redelivered on reconnect. Swallowing here would turn that
35
+ # policy into at-most-once.
36
+ #
37
+ # Handlers are one of:
38
+ # - a Class with #initialize(message) + #call (see {Router::Base});
39
+ # instantiated per message, so handlers stay stateless;
40
+ # - any object responding to #call(message) (proc, method, instance).
41
+ #
42
+ class Router
43
+ ##
44
+ # Decoded view of a Blaze envelope. Wraps the raw envelope Hash and the
45
+ # bot API client that dispatched it, exposing decoded message data and
46
+ # a reply helper over the HTTP message API (no WebSocket access).
47
+ #
48
+ class Message
49
+ # Categories whose data_base64 payload is JSON (everything but
50
+ # PLAIN_TEXT/PLAIN_POST, whose payloads are plain strings).
51
+ JSON_CATEGORIES = %w[
52
+ PLAIN_CONTACT
53
+ PLAIN_STICKER
54
+ PLAIN_LIVE
55
+ PLAIN_LOCATION
56
+ PLAIN_IMAGE
57
+ PLAIN_DATA
58
+ PLAIN_AUDIO
59
+ PLAIN_VIDEO
60
+ APP_CARD
61
+ APP_BUTTON_GROUP
62
+ SYSTEM_ACCOUNT_SNAPSHOT
63
+ MESSAGE_RECALL
64
+ ].freeze
65
+
66
+ attr_reader :raw
67
+
68
+ def initialize(raw, api:)
69
+ @raw = raw.is_a?(Hash) ? raw : {}
70
+ @api = api
71
+ end
72
+
73
+ def action
74
+ @raw['action']
75
+ end
76
+
77
+ # The envelope's message params Hash (empty when absent).
78
+ def params
79
+ data = @raw['data']
80
+ data.is_a?(Hash) ? data : {}
81
+ end
82
+
83
+ def category
84
+ params['category']
85
+ end
86
+
87
+ def conversation_id
88
+ params['conversation_id']
89
+ end
90
+
91
+ def recipient_id
92
+ params['recipient_id']
93
+ end
94
+
95
+ def representative_id
96
+ params['representative_id']
97
+ end
98
+
99
+ def message_id
100
+ params['message_id']
101
+ end
102
+
103
+ # The message's sender (inbound messages carry the sender in user_id).
104
+ def user_id
105
+ params['user_id']
106
+ end
107
+
108
+ def quote_message_id
109
+ params['quote_message_id']
110
+ end
111
+
112
+ ##
113
+ # The decoded message payload: the base64-decoded string for text-like
114
+ # categories, parsed JSON for structured ones. Decoded lazily, once.
115
+ #
116
+ def data
117
+ return @data if defined?(@data)
118
+
119
+ decoded = Base64.decode64(params['data_base64'].to_s)
120
+ @data = JSON_CATEGORIES.include?(category) ? parse_json(decoded) : decoded
121
+ end
122
+
123
+ ##
124
+ # Sends content back to the message's sender in its conversation over
125
+ # the HTTP message API. Supports PLAIN_TEXT (default) and PLAIN_POST;
126
+ # for anything richer, build params via the bound API (exposed by
127
+ # {Blaze::API helpers} on the api client) and send directly.
128
+ #
129
+ # @param content [String] text or post content
130
+ # @param category [String] 'PLAIN_TEXT' or 'PLAIN_POST'
131
+ #
132
+ def reply(content, category: 'PLAIN_TEXT')
133
+ options = { conversation_id:, recipient_id: user_id, data: content }
134
+ payload =
135
+ case category
136
+ when 'PLAIN_TEXT' then @api.plain_text(options)
137
+ when 'PLAIN_POST' then @api.plain_post(options)
138
+ else raise MixinBot::ArgumentError, "unsupported reply category #{category.inspect}"
139
+ end
140
+
141
+ @api.send_message(payload)
142
+ end
143
+
144
+ private
145
+
146
+ def parse_json(str)
147
+ JSON.parse(str)
148
+ rescue JSON::ParserError, EncodingError
149
+ str
150
+ end
151
+ end
152
+
153
+ ##
154
+ # Base class for message handlers: subclass, implement #handle, use
155
+ # #reply / #message. Instantiated per message by the router.
156
+ #
157
+ class Base
158
+ attr_reader :message
159
+
160
+ def initialize(message)
161
+ @message = message
162
+ end
163
+
164
+ def call
165
+ handle
166
+ end
167
+
168
+ def handle
169
+ raise NotImplementedError, "#{self.class} must implement #handle"
170
+ end
171
+
172
+ def reply(content, category: 'PLAIN_TEXT')
173
+ message.reply(content, category:)
174
+ end
175
+ end
176
+
177
+ # A compiled matcher paired with its handler.
178
+ Route = Struct.new(:matcher, :handler) do
179
+ def matches?(message)
180
+ matcher.call(message)
181
+ end
182
+ end
183
+
184
+ # Short aliases accepted by #on (in addition to raw categories, which
185
+ # pass through verbatim when they start with PLAIN_/SYSTEM_/APP_/
186
+ # MESSAGE_).
187
+ CATEGORY_ALIASES = {
188
+ 'text' => 'PLAIN_TEXT',
189
+ 'post' => 'PLAIN_POST',
190
+ 'image' => 'PLAIN_IMAGE',
191
+ 'data' => 'PLAIN_DATA',
192
+ 'file' => 'PLAIN_DATA',
193
+ 'sticker' => 'PLAIN_STICKER',
194
+ 'contact' => 'PLAIN_CONTACT',
195
+ 'audio' => 'PLAIN_AUDIO',
196
+ 'video' => 'PLAIN_VIDEO',
197
+ 'live' => 'PLAIN_LIVE',
198
+ 'location' => 'PLAIN_LOCATION',
199
+ 'transfer' => 'SYSTEM_ACCOUNT_SNAPSHOT',
200
+ 'snapshot' => 'SYSTEM_ACCOUNT_SNAPSHOT',
201
+ 'app_card' => 'APP_CARD',
202
+ 'app_button_group' => 'APP_BUTTON_GROUP',
203
+ 'buttons' => 'APP_BUTTON_GROUP',
204
+ 'recall' => 'MESSAGE_RECALL'
205
+ }.freeze
206
+
207
+ attr_reader :api, :routes
208
+
209
+ ##
210
+ # @param api [MixinBot::API] bot client bound to this router; replies and
211
+ # handler-side API calls use its credentials
212
+ # @param logger [#call, nil] called with (level, detail); defaults to
213
+ # $stderr (same shape as MixinBot::Blaze::Reactor's logger)
214
+ # @yield the routing DSL (see #on)
215
+ #
216
+ def initialize(api: MixinBot.api, logger: nil, &)
217
+ @api = api
218
+ @routes = []
219
+ @logger = logger || ->(level, detail) { warn "[mixin_blaze] #{level}: #{detail}" }
220
+ instance_exec(&) if block_given?
221
+ end
222
+
223
+ ##
224
+ # Registers a route. Matchers:
225
+ # - a category alias or raw category string/symbol ('text', 'app_card',
226
+ # 'PLAIN_TEXT', ...);
227
+ # - a Hash of conditions, e.g. {action: 'CREATE_MESSAGE'} or
228
+ # {category: 'text'} — all conditions must hold.
229
+ #
230
+ # The handler is a Class (instantiated per message) or anything
231
+ # responding to #call(message). Routes match in registration order;
232
+ # the first match wins.
233
+ #
234
+ # @return [self] (chainable)
235
+ #
236
+ def on(matcher, handler = nil, &block)
237
+ if matcher.is_a?(Hash) && matcher.key?(:handler)
238
+ raise MixinBot::ArgumentError, 'handler given twice (positional and :handler key)' if handler || block
239
+
240
+ handler = matcher[:handler]
241
+ matcher = matcher.except(:handler)
242
+ end
243
+ handler ||= block
244
+ raise MixinBot::ArgumentError, 'a handler class or callable is required' unless handler
245
+ unless handler.is_a?(Class) || handler.respond_to?(:call)
246
+ raise MixinBot::ArgumentError, "handler #{handler.inspect} must be a class or respond to #call"
247
+ end
248
+
249
+ @routes << Route.new(compile_matcher(matcher), handler)
250
+ self
251
+ end
252
+
253
+ ##
254
+ # Dispatches one decoded envelope Hash (the reactor's handler contract).
255
+ # Unmatched envelopes are logged and ignored; handler exceptions are
256
+ # logged and re-raised so the reactor's ack policy and containment
257
+ # govern retry/redelivery.
258
+ #
259
+ # @param raw [Hash] decoded envelope with 'action' and 'data' keys
260
+ # @raise [StandardError] when the matched handler raises
261
+ #
262
+ def call(raw)
263
+ message = Message.new(raw, api: @api)
264
+ route = @routes.find { |candidate| candidate.matches?(message) }
265
+
266
+ if route.nil?
267
+ @logger.call(:unmatched, "action=#{message.action.inspect} category=#{message.category.inspect}")
268
+ return
269
+ end
270
+
271
+ dispatch(route.handler, message)
272
+ end
273
+
274
+ # Normalizes a category alias or raw category to its canonical form.
275
+ #
276
+ # @return [String]
277
+ # @raise [MixinBot::ArgumentError] for unknown categories
278
+ #
279
+ def self.normalize_category(value)
280
+ key = value.to_s.downcase
281
+ return CATEGORY_ALIASES[key] if CATEGORY_ALIASES.key?(key)
282
+
283
+ upcased = value.to_s.upcase
284
+ return upcased if upcased.start_with?('PLAIN_', 'SYSTEM_', 'APP_', 'MESSAGE_')
285
+
286
+ raise MixinBot::ArgumentError, "unknown message category #{value.inspect}"
287
+ end
288
+
289
+ private
290
+
291
+ def dispatch(handler, message)
292
+ if handler.is_a?(Class)
293
+ handler.new(message).call
294
+ elsif handler.respond_to?(:call)
295
+ handler.call(message)
296
+ else
297
+ raise MixinBot::ArgumentError, "handler #{handler.inspect} must be a class or respond to #call"
298
+ end
299
+ rescue StandardError => e
300
+ @logger.call(:handler_error, e)
301
+ raise
302
+ end
303
+
304
+ def compile_matcher(matcher)
305
+ case matcher
306
+ when Hash
307
+ conditions = matcher.map { |key, value| compile_condition(key, value) }
308
+ ->(message) { conditions.all? { |condition| condition.call(message) } }
309
+ when String, Symbol
310
+ category = self.class.normalize_category(matcher)
311
+ ->(message) { message.category == category }
312
+ else
313
+ raise MixinBot::ArgumentError,
314
+ "matcher must be a category alias or a conditions Hash, got #{matcher.inspect}"
315
+ end
316
+ end
317
+
318
+ def compile_condition(key, value)
319
+ case key.to_sym
320
+ when :category
321
+ category = self.class.normalize_category(value)
322
+ ->(message) { message.category == category }
323
+ when :action
324
+ action = value.to_s
325
+ ->(message) { message.action == action }
326
+ else
327
+ raise MixinBot::ArgumentError,
328
+ "unknown route condition #{key.inspect} (supported: :category, :action)"
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
@@ -20,7 +20,9 @@ module MixinBot
20
20
  KEY_PREFIX = 'mixin_bot/sessions'
21
21
 
22
22
  def initialize(cache = nil, expires_in: nil)
23
- cache ||= Rails.cache if defined?(Rails)
23
+ # ::Rails (not bare Rails): lexical lookup under module MixinBot must
24
+ # never resolve to a MixinBot-namespaced constant.
25
+ cache ||= ::Rails.cache if defined?(::Rails)
24
26
  raise ArgumentError, 'a cache store is required' if cache.nil?
25
27
 
26
28
  @cache = cache
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'client/error_mapper'
4
+ require_relative 'json_decoder'
4
5
 
5
6
  module MixinBot
6
7
  ##
@@ -24,7 +25,7 @@ module MixinBot
24
25
  f.request :retry, max: 2, interval: 0.5, interval_randomness: 0.5, backoff_factor: 2,
25
26
  exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
26
27
  f.options.timeout = @config.http_timeout if @config.http_timeout
27
- f.response :json
28
+ f.response :json, parser_options: { decoder: [JSONDecoder, :parse] }
28
29
  f.response :logger if @config.debug
29
30
  end
30
31
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'faraday'
4
4
 
5
+ require_relative 'json_decoder'
6
+
5
7
  module MixinBot
6
8
  # Mixin Computer API client (https://computer.mixin.one).
7
9
  class Computer
@@ -15,7 +17,7 @@ module MixinBot
15
17
  def connection
16
18
  @connection ||= Faraday.new(url: BASE_URI) do |f|
17
19
  f.request :json
18
- f.response :json
20
+ f.response :json, parser_options: { decoder: [MixinBot::JSONDecoder, :parse] }
19
21
  f.adapter Faraday.default_adapter
20
22
  end
21
23
  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)