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,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
@@ -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