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,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Outputs
5
+ ##
6
+ # In-memory receipt store: the dedup gate + enqueue bookkeeping the poller
7
+ # needs, backed by nothing but a Hash. Used by the offline test suite and
8
+ # available for non-ActiveRecord hosts; Rails applications use the
9
+ # generated +MixinOutput+ model (see {ReceiptModel}), which implements the
10
+ # same interface:
11
+ #
12
+ # record!(bot_app_id:, output:) # => [receipt, :created] or
13
+ # # [existing, :duplicate]
14
+ # mark_enqueued!(receipt) # stamp the receipt as dispatched
15
+ # unenqueued(bot_app_id:, older_than:) # records not yet dispatched
16
+ # cursor_value(bot_app_id:) # poller resume position
17
+ #
18
+ class MemoryReceiptStore
19
+ ##
20
+ # A receipt row: the poller's dedup + cache record for one output.
21
+ # Field names match the generated +mixin_outputs+ table columns.
22
+ #
23
+ Receipt = Struct.new(
24
+ :id, :bot_app_id, :output_id, :amount, :asset_id, :state,
25
+ :transaction_hash, :output_index, :sequence, :created_at,
26
+ :memo, :opponent_id, :trace_id, :snapshot_bridged,
27
+ :enqueued_at, :recorded_at,
28
+ keyword_init: true
29
+ ) do
30
+ def snapshot_bridged?
31
+ snapshot_bridged ? true : false
32
+ end
33
+
34
+ # Writes resolved snapshot data back onto the receipt (one-time cache)
35
+ # and marks the bridge as attempted — even when it failed with all-nil
36
+ # fields, so it is not retried per job.
37
+ def cache_snapshot!(memo:, opponent_id:, trace_id:)
38
+ self.memo = memo
39
+ self.opponent_id = opponent_id
40
+ self.trace_id = trace_id
41
+ self.snapshot_bridged = true
42
+ end
43
+ end
44
+
45
+ def initialize(clock: -> { Time.now })
46
+ @records = {}
47
+ @sequence = 0
48
+ @clock = clock
49
+ end
50
+
51
+ def record!(bot_app_id:, output:)
52
+ key = [bot_app_id, output['output_id']]
53
+ existing = @records[key]
54
+ return [existing, :duplicate] if existing
55
+
56
+ @sequence += 1
57
+ receipt = Receipt.new(
58
+ id: @sequence,
59
+ bot_app_id:,
60
+ output_id: output['output_id'],
61
+ amount: output['amount'],
62
+ asset_id: output['asset_id'],
63
+ state: output['state'],
64
+ transaction_hash: output['transaction_hash'],
65
+ output_index: output['output_index'],
66
+ sequence: output['sequence'],
67
+ created_at: output['created_at'],
68
+ recorded_at: @clock.call
69
+ )
70
+ @records[key] = receipt
71
+ [receipt, :created]
72
+ end
73
+
74
+ def mark_enqueued!(receipt)
75
+ receipt.enqueued_at = @clock.call
76
+ end
77
+
78
+ def unenqueued(bot_app_id:, older_than:)
79
+ @records.values.select do |receipt|
80
+ receipt.bot_app_id == bot_app_id &&
81
+ receipt.enqueued_at.nil? &&
82
+ receipt.recorded_at < older_than
83
+ end
84
+ end
85
+
86
+ # The poller's resume position: the newest output sequence this bot has
87
+ # recorded — unique and monotonic, and the key the outputs API paginates
88
+ # on (nil = fetch from the beginning).
89
+ def cursor_value(bot_app_id:)
90
+ @records.values
91
+ .select { |receipt| receipt.bot_app_id == bot_app_id }
92
+ .filter_map(&:sequence)
93
+ .max
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Outputs
5
+ ##
6
+ # The polling loop: fetches a page of the bot's outputs oldest-first,
7
+ # records unseen outputs in the receipt store (the dedup gate), enqueues
8
+ # every matching processor through the job queue, and advances its cursor
9
+ # only after the whole page is absorbed.
10
+ #
11
+ # The resume cursor is derived from the receipts themselves — the newest
12
+ # output sequence recorded for the bot (unique, monotonic, and the key
13
+ # the outputs API paginates on) — and kept in memory afterwards: receipts
14
+ # recorded past a mid-page failure cover the remainder (the API returns
15
+ # outputs strictly after the offset), so there is no gap and no separate
16
+ # cursor state.
17
+ #
18
+ # Each loop iteration first sweeps receipts that were recorded but never
19
+ # dispatched (a crash between the two steps), re-evaluating predicates and
20
+ # enqueueing late — making dispatch at-least-once.
21
+ #
22
+ # All collaborators are injectable for offline testing: sleeper, clock,
23
+ # receipt store, processor list, and the enqueue hook. {#run} blocks until
24
+ # {#stop}; signal wiring is the process's job (the mixin_bot:poller rake
25
+ # task).
26
+ #
27
+ # poller = Poller.new(api: MixinBot.bot(:shop), interval: 5)
28
+ # trap('TERM') { poller.stop }
29
+ # poller.run
30
+ #
31
+ class Poller
32
+ DEFAULT_INTERVAL = 5
33
+ DEFAULT_PAGE_SIZE = 500
34
+
35
+ attr_reader :api, :receipts, :interval, :page_size
36
+
37
+ # rubocop:disable Metrics/ParameterLists
38
+ ##
39
+ # @param api [MixinBot::API] the bot whose outputs are polled
40
+ # @param receipts [#record!, #mark_enqueued!, #unenqueued, #cursor_value]
41
+ # receipt store (default: the registered store from the generated
42
+ # model, else in-memory; Rails hosts normally rely on the registration)
43
+ # @param processors [Array<Class>, nil] processor classes; nil discovers
44
+ # them from the Mixin::Processors namespace on first use
45
+ # @param interval [Numeric] seconds between fetch cycles
46
+ # @param page_size [Integer] outputs fetched per cycle
47
+ # @param state [String] output state filter ('' = all states, covering
48
+ # inbound unspent outputs and outbound spent ones)
49
+ # @param asset [String, nil] restrict polling to one asset id
50
+ # @param members [Array<String>, String, nil] restrict polling to these
51
+ # member user ids (defaults to the bot itself at the API)
52
+ # @param threshold [Integer, nil] members threshold (defaults to the
53
+ # members count at the API)
54
+ # @param enqueuer [#call, nil] called with (processor_class, receipt);
55
+ # defaults to ProcessingJob.perform_later
56
+ # @param sleeper [#call, nil] called with seconds between cycles
57
+ # @param clock [#call, nil] returns current Time (sweep bookkeeping)
58
+ # @param logger [#call, nil] called with (level, detail)
59
+ #
60
+ def initialize(api: MixinBot.api, receipts: nil,
61
+ processors: nil, interval: DEFAULT_INTERVAL, page_size: DEFAULT_PAGE_SIZE, state: '',
62
+ asset: nil, members: nil, threshold: nil,
63
+ enqueuer: nil, sleeper: nil, clock: nil, logger: nil)
64
+ @api = api
65
+ @bot_app_id = api.config.app_id
66
+ @receipts = receipts || Outputs.receipt_store || MemoryReceiptStore.new
67
+ @processors = processors
68
+ @interval = interval
69
+ @page_size = page_size
70
+ @state = state
71
+ @asset = asset
72
+ @members = Array(members) if members
73
+ @threshold = threshold
74
+ @enqueuer = enqueuer || ->(processor, receipt) { ProcessingJob.perform_later(receipt.id, processor.name) }
75
+ @sleeper = sleeper || ->(seconds) { sleep seconds }
76
+ @clock = clock || -> { Time.now }
77
+ @logger = logger || Outputs.logger
78
+ @stopping = false
79
+ @cursor_value = nil
80
+ end
81
+ # rubocop:enable Metrics/ParameterLists
82
+
83
+ ##
84
+ # Runs fetch cycles until {#stop} is called. One cycle = sweep stale
85
+ # receipts, fetch a page, absorb every output, advance the cursor.
86
+ # Cycle errors are logged and retried after the interval; the cursor
87
+ # only moves after a fully absorbed page.
88
+ #
89
+ # @return [void]
90
+ #
91
+ def run
92
+ until stopped?
93
+ begin
94
+ poll_once
95
+ rescue StandardError => e
96
+ @logger.call(:poll_error, e)
97
+ end
98
+
99
+ break if stopped?
100
+
101
+ @sleeper.call @interval
102
+ end
103
+ end
104
+
105
+ ##
106
+ # Requests termination; the in-flight cycle finishes first.
107
+ #
108
+ # Signal-safe: a plain flag (no Mutex — trap handlers cannot synchronize).
109
+ def stop
110
+ @stopping = true
111
+ end
112
+
113
+ def stopped?
114
+ @stopping
115
+ end
116
+
117
+ ##
118
+ # One fetch cycle. Exposed for tests and callers that drive the loop
119
+ # themselves.
120
+ #
121
+ # @return [Integer] number of outputs in the fetched page
122
+ #
123
+ def poll_once
124
+ sweep_undispatched
125
+
126
+ page = fetch_page
127
+ page.each { |output| absorb(output) }
128
+
129
+ @cursor_value = page.last['sequence'] if page.last
130
+ page.size
131
+ end
132
+
133
+ private
134
+
135
+ def sweep_undispatched
136
+ cutoff = @clock.call - @interval
137
+ @receipts.unenqueued(bot_app_id: @bot_app_id, older_than: cutoff).each do |receipt|
138
+ dispatch(Envelope.new(receipt, api: @api), receipt)
139
+ end
140
+ end
141
+
142
+ # Resume position: the newest recorded receipt for this bot, held in
143
+ # memory afterwards (advance = in-memory, no persistence needed).
144
+ def fetch_page
145
+ @cursor_value ||= @receipts.cursor_value(bot_app_id: @bot_app_id)
146
+
147
+ response = @api.safe_outputs(
148
+ state: @state, limit: @page_size, offset: @cursor_value.to_s, order: 'ASC',
149
+ asset: @asset, members: @members, threshold: @threshold
150
+ )
151
+ Array(response['data'])
152
+ end
153
+
154
+ def absorb(output)
155
+ receipt, status = @receipts.record!(bot_app_id: @bot_app_id, output:)
156
+ return if status == :duplicate
157
+
158
+ dispatch(Envelope.new(receipt, api: @api), receipt)
159
+ end
160
+
161
+ # Evaluates every processor against the envelope and enqueues the
162
+ # matches, then stamps the receipt as dispatched. An output matching no
163
+ # processor is stamped without work.
164
+ def dispatch(envelope, receipt)
165
+ processor_list(envelope).each { |processor| @enqueuer.call(processor, receipt) }
166
+ @receipts.mark_enqueued!(receipt)
167
+ rescue StandardError => e
168
+ # Leave the receipt undispatched so the next sweep retries the enqueue.
169
+ @logger.call(:dispatch_error, "#{e.class}: #{e.message} (output #{receipt.output_id})")
170
+ end
171
+
172
+ def processor_list(envelope)
173
+ @processors ||= Processors.discover
174
+ Processors.for_envelope(envelope, processors: @processors)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'active_job/base'
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ module MixinBot
10
+ module Outputs
11
+ ##
12
+ # The poller's processing job — carries only (receipt id, processor class
13
+ # name) in its payload and rehydrates everything else at run time: the
14
+ # receipt from the application's receipt loader, the bot API client from
15
+ # the receipt's app id, and the envelope from the receipt.
16
+ #
17
+ # Defined only when ActiveJob is available (it is in every Rails stack
18
+ # that runs a job queue).
19
+ #
20
+ if defined?(ActiveJob::Base)
21
+ class ProcessingJob < ActiveJob::Base
22
+ queue_as :mixin_outputs
23
+
24
+ # @param receipt_id [Object] the receipt record's primary key
25
+ # @param processor_name [String] fully-qualified processor class name
26
+ def perform(receipt_id, processor_name)
27
+ loader = MixinBot::Outputs.receipt_loader
28
+ unless loader
29
+ raise ArgumentError,
30
+ 'no receipt loader configured: include MixinBot::Outputs::ReceiptModel ' \
31
+ 'in your receipt model (the mixin_bot:outputs generator does this)'
32
+ end
33
+
34
+ receipt = loader.call(receipt_id)
35
+ envelope = Envelope.new(receipt, api: api_for(receipt))
36
+ Processors.resolve(processor_name).new(envelope).call
37
+ end
38
+
39
+ private
40
+
41
+ # The receipt's own bot: nil bot_app_id = the default bot; an explicit
42
+ # app id that no longer resolves is a configuration error, never a
43
+ # reason to spend the default bot's credentials on it.
44
+ def api_for(receipt)
45
+ app_id = receipt.respond_to?(:bot_app_id) ? receipt.bot_app_id : nil
46
+ return MixinBot.api if app_id.blank?
47
+
48
+ MixinBot.bot_by_app_id(app_id) ||
49
+ raise(MixinBot::NotFoundError, "no bot registered for app_id #{app_id}")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Outputs
5
+ ##
6
+ # Base class for output processors. Subclass, implement class-level
7
+ # {matches?} and instance-level {#process}:
8
+ #
9
+ # module Mixin
10
+ # module Processors
11
+ # class OrderDeposit < MixinBot::Outputs::Processor
12
+ # def self.matches?(envelope)
13
+ # !envelope.spent? && envelope.memo.to_s.start_with?('ORDER-')
14
+ # end
15
+ #
16
+ # def process
17
+ # Order.settle_by_deposit!(envelope)
18
+ # end
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ # Processors live in app/mixin/processors (Rails autoloads them as
24
+ # Mixin::Processors::*). Every processor whose predicate matches an output
25
+ # gets a job enqueued — matches are independent side effects, and an
26
+ # output matching no processor is recorded only.
27
+ #
28
+ # Enqueue is at-least-once: processors should tolerate rare duplicate
29
+ # runs and can use the envelope's output_id as a natural idempotency key.
30
+ #
31
+ class Processor
32
+ def initialize(envelope)
33
+ @envelope = envelope
34
+ end
35
+
36
+ def call
37
+ process
38
+ end
39
+
40
+ attr_reader :envelope
41
+
42
+ # Override with the processor's selection predicate.
43
+ def self.matches?(_envelope)
44
+ false
45
+ end
46
+
47
+ def process
48
+ raise NotImplementedError, "#{self.class} must implement #process"
49
+ end
50
+ end
51
+
52
+ ##
53
+ # Processor discovery and selection over the Mixin::Processors namespace.
54
+ #
55
+ module Processors
56
+ NAMESPACE_NAME = 'Mixin::Processors'
57
+
58
+ module_function
59
+
60
+ ##
61
+ # All processor classes currently defined under Mixin::Processors. Under
62
+ # Rails the namespace is eager-loaded first so the poller process sees
63
+ # every processor without referencing each one.
64
+ #
65
+ # @return [Array<Class>]
66
+ #
67
+ def discover
68
+ eager_load_namespace! if defined?(::Rails.application) && ::Rails.application.respond_to?(:autoloaders)
69
+
70
+ namespace = namespace_class
71
+ return [] if namespace.nil?
72
+
73
+ namespace.constants.filter_map do |const|
74
+ candidate = namespace.const_get(const)
75
+ candidate if candidate.is_a?(Class) && candidate < Processor
76
+ end
77
+ end
78
+
79
+ # Finds a processor by fully-qualified class name (the payload the
80
+ # poller's jobs carry).
81
+ #
82
+ # @param name [String]
83
+ # @return [Class]
84
+ # @raise [NameError] when no such processor is loaded
85
+ #
86
+ def resolve(name)
87
+ constant = Object.const_get(name)
88
+ raise NameError, "#{name} is not a MixinBot::Outputs::Processor" unless constant.is_a?(Class) && constant < Processor
89
+
90
+ constant
91
+ end
92
+
93
+ ##
94
+ # Processors whose predicate matches the envelope.
95
+ #
96
+ # @return [Array<Class>]
97
+ #
98
+ def for_envelope(envelope, processors:)
99
+ processors.select { |processor| processor.matches?(envelope) }
100
+ end
101
+
102
+ def namespace_class
103
+ Object.const_get(NAMESPACE_NAME)
104
+ rescue NameError
105
+ nil
106
+ end
107
+
108
+ def eager_load_namespace!
109
+ autoloader = ::Rails.autoloaders.main
110
+ namespace = namespace_class
111
+ return if namespace.nil?
112
+
113
+ if autoloader.respond_to?(:eager_load_namespace)
114
+ autoloader.eager_load_namespace(namespace)
115
+ else
116
+ ::Rails.application.eager_load!
117
+ end
118
+ rescue Zeitwerk::SetupRequired, StandardError
119
+ # Best effort: an eager-load problem must not stop polling.
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,86 @@
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 +MixinOutput+ receipt model
9
+ # (mixin_bot:outputs generator). Implements the receipt-store interface
10
+ # the poller consumes, on top of the table's unique
11
+ # +(bot_app_id, output_id)+ index:
12
+ #
13
+ # class MixinOutput < ApplicationRecord
14
+ # include MixinBot::Outputs::ReceiptModel
15
+ # end
16
+ #
17
+ # Including the model also registers it as the integration's receipt
18
+ # store (the mixin_bot:poller rake task) and as the processing job's
19
+ # receipt loader. The poller's resume cursor is derived from the receipts
20
+ # themselves — the newest output sequence per bot — so there is no
21
+ # separate cursor table.
22
+ #
23
+ # Required columns (see the generated migration): bot_app_id, output_id,
24
+ # amount, asset_id, state, transaction_hash, output_index, sequence
25
+ # (the outputs API's pagination key — the poller's cursor), memo,
26
+ # opponent_id, trace_id, snapshot_bridged, enqueued_at, plus Rails
27
+ # timestamps (created_at backs the sweep query).
28
+ #
29
+ module ReceiptModel
30
+ extend ActiveSupport::Concern
31
+
32
+ class_methods do
33
+ # Records the output or reports it as already seen. Uniqueness is
34
+ # enforced by the (bot_app_id, output_id) index, so concurrent pollers
35
+ # race safely.
36
+ def record!(bot_app_id:, output:)
37
+ existing = find_by(bot_app_id:, output_id: output['output_id'])
38
+ return [existing, :duplicate] if existing
39
+
40
+ receipt = create!(
41
+ bot_app_id:,
42
+ output_id: output['output_id'],
43
+ amount: output['amount'],
44
+ asset_id: output['asset_id'],
45
+ state: output['state'],
46
+ transaction_hash: output['transaction_hash'],
47
+ output_index: output['output_index'],
48
+ output_created_at: output['created_at']
49
+ )
50
+ [receipt, :created]
51
+ rescue ActiveRecord::RecordNotUnique
52
+ [find_by!(bot_app_id:, output_id: output['output_id']), :duplicate]
53
+ end
54
+
55
+ def mark_enqueued!(receipt)
56
+ receipt.update!(enqueued_at: Time.now)
57
+ end
58
+
59
+ # created_at is the Rails timestamp (when the receipt row was written),
60
+ # so the sweep's cutoff means "recorded before this moment".
61
+ def unenqueued(bot_app_id:, older_than:)
62
+ where(bot_app_id:, enqueued_at: nil).where(created_at: ...older_than)
63
+ end
64
+
65
+ # The poller's resume position: the newest output sequence recorded
66
+ # for this bot — unique and monotonic, and the key the outputs API
67
+ # paginates on (nil = fetch from the beginning). Derived from the
68
+ # receipts themselves — no separate cursor state.
69
+ def cursor_value(bot_app_id:)
70
+ where(bot_app_id:).maximum(:sequence)
71
+ end
72
+ end
73
+
74
+ # Caches the envelope's resolved snapshot data (one bridge attempt per
75
+ # output — including failures, which cache nils and the flag).
76
+ def cache_snapshot!(memo:, opponent_id:, trace_id:)
77
+ update!(memo:, opponent_id:, trace_id:, snapshot_bridged: true) unless frozen?
78
+ end
79
+
80
+ included do
81
+ MixinBot::Outputs.receipt_loader = method(:find)
82
+ MixinBot::Outputs.receipt_store = self
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ ##
5
+ # Output (transfer) polling integration: delivers every output touching the
6
+ # bot's Safe UTXO set — inbound or outbound — to application processor
7
+ # classes exactly once, via a standalone polling process and the
8
+ # application's job queue.
9
+ #
10
+ # poller = MixinBot::Outputs::Poller.new(api: MixinBot.bot(:shop))
11
+ # poller.run # blocks; see the mixin_bot:poller rake task
12
+ #
13
+ module Outputs
14
+ class << self
15
+ # Shared logger for the outputs integration; called with (level, detail),
16
+ # same shape as MixinBot::Blaze::Reactor's logger. Injectable per
17
+ # component; this default is the fallback.
18
+ attr_accessor :logger
19
+
20
+ # Called with a receipt id to reload the receipt record when a processing
21
+ # job runs — set by the generated receipt model (ActiveRecord host apps).
22
+ # Without a loader, processing jobs raise a clear error explaining how to
23
+ # wire one.
24
+ attr_accessor :receipt_loader
25
+
26
+ # The receipt store the poller process uses — set by the generated
27
+ # receipt model when it loads. The mixin_bot:poller rake task refuses to
28
+ # run without one (polling with an in-memory store would silently lose
29
+ # dedup across restarts).
30
+ attr_accessor :receipt_store
31
+ end
32
+
33
+ self.logger = ->(level, detail) { warn "[mixin_outputs] #{level}: #{detail}" }
34
+ end
35
+ end
36
+
37
+ require_relative 'outputs/envelope'
38
+ require_relative 'outputs/memory_stores'
39
+ require_relative 'outputs/processors'
40
+ require_relative 'outputs/poller'
41
+ require_relative 'outputs/processing_job'
42
+ require_relative 'outputs/receipt_model'
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module MixinBot
6
+ # Registers the Rails integration's rake tasks and application-code
7
+ # conventions. Lives at lib/mixin_bot/rails/railtie.rb (loaded by
8
+ # `require 'mixin_bot/rails'`), named +MixinBot::Railtie+ — deliberately not
9
+ # +MixinBot::Rails+, which would lexically shadow ::Rails for code written
10
+ # inside module MixinBot.
11
+ #
12
+ # Application conventions:
13
+ # - +app/mixin/handlers+ hosts Blaze message handler classes
14
+ # (+Mixin::Handlers::*+) and +app/mixin/processors+ hosts output
15
+ # processor classes (+Mixin::Processors::*+). Rails' default path
16
+ # inference would make +app/mixin+ itself a Zeitwerk root (mapping files
17
+ # to +Handlers::*+ / +Processors::*+, not +Mixin::Handlers::*+), so the
18
+ # railtie excludes it from the defaults and re-registers it with the
19
+ # +Mixin+ namespace explicitly. Eager loading is the poller process's
20
+ # responsibility (it eager-loads before discovering processors),
21
+ # mirroring how Solid Queue's supervisor boots.
22
+ #
23
+ # Generator discovery needs no registration either: railties scans loaded
24
+ # gems for +lib/generators+ automatically.
25
+ class Railtie < ::Rails::Railtie
26
+ railtie_name 'mixin_bot'
27
+
28
+ initializer 'mixin_bot.exclude_default_mixin_root', before: :set_autoload_paths do |app|
29
+ app.config.paths.add 'app/mixin', autoload: false, eager_load: false
30
+ end
31
+
32
+ initializer 'mixin_bot.push_mixin_root', after: :set_autoload_paths, before: :setup_main_autoloader do |app|
33
+ mixin_root = app.root.join('app/mixin')
34
+ next unless mixin_root.directory?
35
+
36
+ Object.const_set(:Mixin, Module.new) unless Object.const_defined?(:Mixin)
37
+ app.autoloaders.main.push_dir(mixin_root, namespace: Mixin)
38
+ end
39
+
40
+ rake_tasks do
41
+ load File.expand_path('tasks/poller.rake', __dir__)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :mixin_bot do
4
+ desc 'Poll Mixin outputs for the default bot (or the given [bot_name]) and enqueue processor jobs'
5
+ task :poller, [:bot] => :environment do |_task, args|
6
+ # Processor classes live in app/mixin/processors; eager load so discovery
7
+ # sees them (the poller process never references them directly).
8
+ Rails.application.eager_load!
9
+
10
+ unless MixinBot::Outputs.receipt_store
11
+ raise MixinBot::ArgumentError,
12
+ 'no receipt store configured: run `rails g mixin_bot:outputs` (or include ' \
13
+ 'MixinBot::Outputs::ReceiptModel in your receipt model) so polling dedup ' \
14
+ 'survives restarts'
15
+ end
16
+
17
+ api = args[:bot] ? MixinBot.bot(args[:bot]) : MixinBot.api
18
+ poller = MixinBot::Outputs::Poller.new(
19
+ api:,
20
+ receipts: MixinBot::Outputs.receipt_store,
21
+ interval: Integer(ENV.fetch('MIXIN_BOT_POLL_INTERVAL', 5)),
22
+ asset: ENV.fetch('MIXIN_BOT_POLL_ASSET', nil),
23
+ members: ENV['MIXIN_BOT_POLL_MEMBERS']&.split(','),
24
+ threshold: ENV['MIXIN_BOT_POLL_THRESHOLD']&.then { |t| Integer(t) }
25
+ )
26
+
27
+ %w[TERM INT].each { |signal| trap(signal) { poller.stop } }
28
+
29
+ MixinBot::Outputs.logger.call(:info, "poller started (pid=#{Process.pid})")
30
+ poller.run
31
+ MixinBot::Outputs.logger.call(:info, 'poller stopped')
32
+ end
33
+ end