mixin_bot 3.0.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -1
  3. data/API_COVERAGE.md +1 -0
  4. data/CHANGELOG.md +11 -1
  5. data/README.md +75 -1
  6. data/docs/agent/cookbook.md +23 -0
  7. data/lib/generators/mixin_bot/authentication/USAGE +29 -0
  8. data/lib/generators/mixin_bot/authentication/authentication_generator.rb +82 -0
  9. data/lib/generators/mixin_bot/authentication/templates/app/controllers/concerns/authentication.rb.tt +52 -0
  10. data/lib/generators/mixin_bot/authentication/templates/app/controllers/sessions_controller.rb.tt +24 -0
  11. data/lib/generators/mixin_bot/authentication/templates/app/models/current.rb.tt +4 -0
  12. data/lib/generators/mixin_bot/authentication/templates/app/models/session.rb.tt +3 -0
  13. data/lib/generators/mixin_bot/authentication/templates/app/models/user.rb.tt +12 -0
  14. data/lib/generators/mixin_bot/authentication/templates/app/views/sessions/new.html.erb.tt +3 -0
  15. data/lib/generators/mixin_bot/authentication/templates/config/initializers/omniauth.rb.tt +21 -0
  16. data/lib/generators/mixin_bot/blaze/USAGE +19 -0
  17. data/lib/generators/mixin_bot/blaze/blaze_generator.rb +43 -0
  18. data/lib/generators/mixin_bot/blaze/templates/app/mixin/handlers/text_handler.rb +14 -0
  19. data/lib/generators/mixin_bot/blaze/templates/config/initializers/mixin_bot_blaze.rb +23 -0
  20. data/lib/generators/mixin_bot/install/USAGE +24 -0
  21. data/lib/generators/mixin_bot/install/install_generator.rb +50 -0
  22. data/lib/generators/mixin_bot/notifications/USAGE +13 -0
  23. data/lib/generators/mixin_bot/notifications/notifications_generator.rb +49 -0
  24. data/lib/generators/mixin_bot/notifications/templates/app/notifications/payment_received_notification.rb +24 -0
  25. data/lib/generators/mixin_bot/outputs/USAGE +29 -0
  26. data/lib/generators/mixin_bot/outputs/outputs_generator.rb +50 -0
  27. data/lib/generators/mixin_bot/outputs/templates/app/mixin/processors/deposit_processor.rb +21 -0
  28. data/lib/generators/mixin_bot/outputs/templates/app/models/mixin_output.rb +8 -0
  29. data/lib/generators/mixin_bot/outputs/templates/db/migrate/create_mixin_outputs.rb.tt +26 -0
  30. data/lib/generators/mixin_bot/transfers/USAGE +37 -0
  31. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/perform_job.rb +13 -0
  32. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/reconcile_job.rb +14 -0
  33. data/lib/generators/mixin_bot/transfers/templates/app/models/mixin_transfer.rb +11 -0
  34. data/lib/generators/mixin_bot/transfers/templates/db/migrate/create_mixin_transfers.rb.tt +23 -0
  35. data/lib/generators/mixin_bot/transfers/transfers_generator.rb +67 -0
  36. data/lib/generators/mixin_bot.rb +10 -0
  37. data/lib/mixin_bot/api/app.rb +20 -0
  38. data/lib/mixin_bot/api/snapshot.rb +5 -0
  39. data/lib/mixin_bot/blaze/reactor.rb +8 -3
  40. data/lib/mixin_bot/blaze/router.rb +333 -0
  41. data/lib/mixin_bot/cache_store_adapter.rb +3 -1
  42. data/lib/mixin_bot/client.rb +2 -1
  43. data/lib/mixin_bot/computer.rb +3 -1
  44. data/lib/mixin_bot/json_decoder.rb +14 -0
  45. data/lib/mixin_bot/notifications/mixin_channel.rb +80 -0
  46. data/lib/mixin_bot/outputs/cursor_model.rb +33 -0
  47. data/lib/mixin_bot/outputs/envelope.rb +160 -0
  48. data/lib/mixin_bot/outputs/memory_stores.rb +97 -0
  49. data/lib/mixin_bot/outputs/poller.rb +178 -0
  50. data/lib/mixin_bot/outputs/processing_job.rb +54 -0
  51. data/lib/mixin_bot/outputs/processors.rb +124 -0
  52. data/lib/mixin_bot/outputs/receipt_model.rb +86 -0
  53. data/lib/mixin_bot/outputs.rb +42 -0
  54. data/lib/mixin_bot/rails/railtie.rb +44 -0
  55. data/lib/mixin_bot/rails/tasks/poller.rake +33 -0
  56. data/lib/mixin_bot/rails.rb +34 -0
  57. data/lib/mixin_bot/registry.rb +79 -0
  58. data/lib/mixin_bot/transfers/model.rb +112 -0
  59. data/lib/mixin_bot/transfers/performer.rb +64 -0
  60. data/lib/mixin_bot/transfers/reconcile.rb +53 -0
  61. data/lib/mixin_bot/transfers.rb +23 -0
  62. data/lib/mixin_bot/version.rb +1 -1
  63. data/lib/mixin_bot.rb +3 -0
  64. data/llms.txt +1 -1
  65. metadata +51 -2
@@ -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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ # json 3.x made +JSON.parse+ keyword-only, while faraday's JSON middleware
5
+ # calls its decoder with positional options. Splatted keywords keep this
6
+ # compatible with both json 2.x and 3.x.
7
+ module JSONDecoder
8
+ module_function
9
+
10
+ def parse(body, options = {})
11
+ JSON.parse(body, **options)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads only when the Noticed gem is already loaded (it is in every app that
4
+ # declares `deliver_by :mixin`); requiring the integration layer without
5
+ # Noticed neither raises nor loads this file's contents.
6
+ return unless defined?(Noticed::Channel)
7
+
8
+ module MixinBot
9
+ module Notifications
10
+ ##
11
+ # Noticed delivery channel that sends the notification as a Mixin message
12
+ # over the HTTP message API.
13
+ #
14
+ # Notification-class contract (all resolution lives in the notification,
15
+ # so the channel stays a thin adapter over Noticed's surface):
16
+ #
17
+ # class PaymentReceivedNotification < Noticed::Notification
18
+ # deliver_by :mixin # or: deliver_by :mixin, bot: :shop
19
+ #
20
+ # def mixin_recipient # Mixin user id to deliver to
21
+ # recipient.mixin_user_id
22
+ # end
23
+ #
24
+ # def mixin_message
25
+ # { category: 'PLAIN_TEXT', content: "received #{params[:amount]}" }
26
+ # end
27
+ # end
28
+ #
29
+ # mixin_message supports {category: 'PLAIN_TEXT'|'PLAIN_POST', content:}
30
+ # and {category: 'APP_CARD', card: {title:, description:, action:, ...}}.
31
+ # When the notification class does not define #mixin_recipient, the
32
+ # channel falls back to the recipient record's #mixin_user_id.
33
+ #
34
+ # Delivery failures raise out of #deliver — Noticed captures them per
35
+ # delivery without aborting sibling channels.
36
+ #
37
+ class MixinChannel < ::Noticed::Channel
38
+ def deliver
39
+ api.send_message(payload)
40
+ end
41
+
42
+ private
43
+
44
+ def api
45
+ @api ||=
46
+ if params[:bot]
47
+ MixinBot.bot(params[:bot])
48
+ else
49
+ MixinBot.api
50
+ end
51
+ end
52
+
53
+ def payload
54
+ message = notification.try(:mixin_message) ||
55
+ raise(MixinBot::ArgumentError,
56
+ "#{notification.class} must define #mixin_message for the mixin channel")
57
+
58
+ options = { recipient_id: recipient_id, data: message[:content] }
59
+ case message[:category]
60
+ when 'PLAIN_POST' then api.plain_post(options)
61
+ when 'APP_CARD' then api.app_card(options.merge(data: message[:card]))
62
+ else api.plain_text(options)
63
+ end
64
+ end
65
+
66
+ def recipient_id
67
+ id =
68
+ if notification.respond_to?(:mixin_recipient)
69
+ notification.mixin_recipient
70
+ elsif recipient.respond_to?(:mixin_user_id)
71
+ recipient.mixin_user_id
72
+ end
73
+
74
+ raise MixinBot::ArgumentError, 'could not resolve a Mixin recipient id' if id.blank?
75
+
76
+ id
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module MixinBot
6
+ module Outputs
7
+ ##
8
+ # ActiveRecord concern for the generated +MixinPollerCursor+ model
9
+ # (mixin_bot:outputs generator): one row per bot app id, remembering the
10
+ # last absorbed output position (the outputs API's own created_at value).
11
+ #
12
+ # class MixinPollerCursor < ApplicationRecord
13
+ # include MixinBot::Outputs::CursorModel
14
+ # end
15
+ #
16
+ # Required columns: bot_app_id (unique), value.
17
+ #
18
+ module CursorModel
19
+ extend ActiveSupport::Concern
20
+
21
+ class_methods do
22
+ def value(bot_app_id:)
23
+ find_by(bot_app_id:)&.value
24
+ end
25
+
26
+ def advance!(bot_app_id:, value:)
27
+ record = find_or_initialize_by(bot_app_id:)
28
+ record.update!(value:)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module MixinBot
6
+ module Outputs
7
+ ##
8
+ # Application-facing view of one Mixin output (a transfer touching the
9
+ # bot's Safe UTXO set — inbound or outbound).
10
+ #
11
+ # The output stream itself carries amount/asset/state but no memo; the
12
+ # memo, counterparty, and trace id are bridged lazily — at most once per
13
+ # output — through the snapshot notification API
14
+ # (+create_safe_snapshot_notification+). A failed bridge logs a warning
15
+ # and yields nil fields; processing continues without them.
16
+ #
17
+ # Wraps a receipt record (the poller's dedup row): output fields are read
18
+ # from it and resolved snapshot data is cached back onto it. Receipt-like
19
+ # objects must expose:
20
+ # output_id, amount (String), asset_id, state, transaction_hash,
21
+ # output_index, memo, opponent_id, trace_id
22
+ # snapshot_bridged? # bridge already attempted (success or not)
23
+ # cache_snapshot!(memo:, opponent_id:, trace_id:) # write-back
24
+ #
25
+ # envelope.amount # => BigDecimal
26
+ # envelope.memo # => String or nil (bridged on first access)
27
+ # envelope.spent? # => true when the output is spent
28
+ #
29
+ class Envelope
30
+ attr_reader :receipt, :api
31
+
32
+ ##
33
+ # @param receipt [Object] receipt record backing this envelope
34
+ # @param api [MixinBot::API] bot client used for the snapshot bridge
35
+ #
36
+ def initialize(receipt, api:)
37
+ @receipt = receipt
38
+ @api = api
39
+ end
40
+
41
+ def output_id
42
+ receipt.output_id
43
+ end
44
+
45
+ # The output's amount as a BigDecimal.
46
+ def amount
47
+ BigDecimal(receipt.amount.to_s)
48
+ end
49
+
50
+ def asset_id
51
+ receipt.asset_id
52
+ end
53
+
54
+ def transaction_hash
55
+ receipt.transaction_hash
56
+ end
57
+
58
+ def output_index
59
+ receipt.output_index
60
+ end
61
+
62
+ # The output's kernel sequence — unique and monotonic; the key the
63
+ # outputs API paginates on and the value processors can key on for
64
+ # ordering.
65
+ def sequence
66
+ receipt.sequence
67
+ end
68
+
69
+ # True when the output is spent (outbound from the bot's perspective).
70
+ def spent?
71
+ receipt.state == 'spent'
72
+ end
73
+
74
+ def state
75
+ receipt.state
76
+ end
77
+
78
+ ##
79
+ # Snapshot-derived memo; nil when the bridge failed or the snapshot has
80
+ # no memo. Bridged at most once per output.
81
+ #
82
+ def memo
83
+ bridge_snapshot.memo
84
+ end
85
+
86
+ # Snapshot-derived counterparty user id; nil on bridge failure.
87
+ def opponent_id
88
+ bridge_snapshot.opponent_id
89
+ end
90
+
91
+ # Snapshot-derived trace id (the sender's idempotency trace); nil on
92
+ # bridge failure.
93
+ def trace_id
94
+ bridge_snapshot.trace_id
95
+ end
96
+
97
+ private
98
+
99
+ # Returns the memo/opponent/trace triple after a single bridge attempt.
100
+ # Receipts whose bridge already ran (success OR failure — the flag is
101
+ # written in both cases) short-circuit the API call, so each output is
102
+ # bridged at most once per receipt lifetime, not just per Envelope.
103
+ def bridge_snapshot
104
+ return @bridge_snapshot if defined?(@bridge_snapshot)
105
+
106
+ if receipt.respond_to?(:snapshot_bridged?) && receipt.snapshot_bridged?
107
+ return Snapshot.new(memo: receipt.memo, opponent_id: receipt.opponent_id,
108
+ trace_id: receipt.trace_id, bridged: true)
109
+ end
110
+
111
+ fetch_snapshot
112
+ end
113
+
114
+ def fetch_snapshot
115
+ response = api.create_safe_snapshot_notification(
116
+ transaction_hash: receipt.transaction_hash,
117
+ output_index: receipt.output_index,
118
+ receiver_id: api.config.app_id
119
+ )
120
+
121
+ snapshot = response['data'].is_a?(Hash) ? response['data'] : {}
122
+
123
+ fields = Snapshot.new(
124
+ memo: snapshot['memo'],
125
+ opponent_id: snapshot['opponent_id'],
126
+ trace_id: snapshot['trace_id'],
127
+ bridged: true
128
+ )
129
+
130
+ if receipt.respond_to?(:cache_snapshot!)
131
+ receipt.cache_snapshot!(
132
+ memo: fields.memo,
133
+ opponent_id: fields.opponent_id,
134
+ trace_id: fields.trace_id
135
+ )
136
+ end
137
+
138
+ fields
139
+ rescue StandardError => e
140
+ MixinBot::Outputs.logger.call(:snapshot_bridge_error,
141
+ "#{e.class}: #{e.message} (output #{receipt.output_id})")
142
+ # Mark the attempt even on failure so later jobs don't re-POST.
143
+ receipt.cache_snapshot!(memo: nil, opponent_id: nil, trace_id: nil) if receipt.respond_to?(:cache_snapshot!)
144
+ Snapshot.new(memo: nil, opponent_id: nil, trace_id: nil, bridged: false)
145
+ end
146
+
147
+ # Immutable triple of bridged snapshot fields.
148
+ Snapshot = Struct.new(:memo, :opponent_id, :trace_id, keyword_init: true) do
149
+ def initialize(bridged:, memo: nil, opponent_id: nil, trace_id: nil)
150
+ super(memo:, opponent_id:, trace_id:)
151
+ @bridged = bridged
152
+ end
153
+
154
+ def bridged?
155
+ @bridged
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end