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.
- checksums.yaml +4 -4
- data/AGENTS.md +3 -3
- data/API_COVERAGE.md +1 -0
- data/CHANGELOG.md +22 -1
- data/README.md +116 -5
- data/docs/agent/cookbook.md +49 -0
- data/examples/blaze_async.rb +6 -4
- data/lib/generators/mixin_bot/authentication/USAGE +29 -0
- data/lib/generators/mixin_bot/authentication/authentication_generator.rb +82 -0
- data/lib/generators/mixin_bot/authentication/templates/app/controllers/concerns/authentication.rb.tt +52 -0
- data/lib/generators/mixin_bot/authentication/templates/app/controllers/sessions_controller.rb.tt +24 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/current.rb.tt +4 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/session.rb.tt +3 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/user.rb.tt +12 -0
- data/lib/generators/mixin_bot/authentication/templates/app/views/sessions/new.html.erb.tt +3 -0
- data/lib/generators/mixin_bot/authentication/templates/config/initializers/omniauth.rb.tt +21 -0
- data/lib/generators/mixin_bot/blaze/USAGE +19 -0
- data/lib/generators/mixin_bot/blaze/blaze_generator.rb +43 -0
- data/lib/generators/mixin_bot/blaze/templates/app/mixin/handlers/text_handler.rb +14 -0
- data/lib/generators/mixin_bot/blaze/templates/config/initializers/mixin_bot_blaze.rb +23 -0
- data/lib/generators/mixin_bot/install/USAGE +24 -0
- data/lib/generators/mixin_bot/install/install_generator.rb +50 -0
- data/lib/generators/mixin_bot/notifications/USAGE +13 -0
- data/lib/generators/mixin_bot/notifications/notifications_generator.rb +49 -0
- data/lib/generators/mixin_bot/notifications/templates/app/notifications/payment_received_notification.rb +24 -0
- data/lib/generators/mixin_bot/outputs/USAGE +29 -0
- data/lib/generators/mixin_bot/outputs/outputs_generator.rb +50 -0
- data/lib/generators/mixin_bot/outputs/templates/app/mixin/processors/deposit_processor.rb +21 -0
- data/lib/generators/mixin_bot/outputs/templates/app/models/mixin_output.rb +8 -0
- data/lib/generators/mixin_bot/outputs/templates/db/migrate/create_mixin_outputs.rb.tt +26 -0
- data/lib/generators/mixin_bot/transfers/USAGE +37 -0
- data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/perform_job.rb +13 -0
- data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/reconcile_job.rb +14 -0
- data/lib/generators/mixin_bot/transfers/templates/app/models/mixin_transfer.rb +11 -0
- data/lib/generators/mixin_bot/transfers/templates/db/migrate/create_mixin_transfers.rb.tt +23 -0
- data/lib/generators/mixin_bot/transfers/transfers_generator.rb +67 -0
- data/lib/generators/mixin_bot.rb +10 -0
- data/lib/mixin_bot/api/app.rb +20 -0
- data/lib/mixin_bot/api/blaze_async.rb +5 -1
- data/lib/mixin_bot/api/snapshot.rb +5 -0
- data/lib/mixin_bot/blaze/reactor.rb +249 -0
- data/lib/mixin_bot/blaze/router.rb +333 -0
- data/lib/mixin_bot/cache_store_adapter.rb +3 -1
- data/lib/mixin_bot/client.rb +2 -1
- data/lib/mixin_bot/computer.rb +3 -1
- data/lib/mixin_bot/configuration.rb +52 -0
- data/lib/mixin_bot/json_decoder.rb +14 -0
- data/lib/mixin_bot/notifications/mixin_channel.rb +80 -0
- data/lib/mixin_bot/outputs/cursor_model.rb +33 -0
- data/lib/mixin_bot/outputs/envelope.rb +160 -0
- data/lib/mixin_bot/outputs/memory_stores.rb +97 -0
- data/lib/mixin_bot/outputs/poller.rb +178 -0
- data/lib/mixin_bot/outputs/processing_job.rb +54 -0
- data/lib/mixin_bot/outputs/processors.rb +124 -0
- data/lib/mixin_bot/outputs/receipt_model.rb +86 -0
- data/lib/mixin_bot/outputs.rb +42 -0
- data/lib/mixin_bot/rails/railtie.rb +44 -0
- data/lib/mixin_bot/rails/tasks/poller.rake +33 -0
- data/lib/mixin_bot/rails.rb +34 -0
- data/lib/mixin_bot/registry.rb +79 -0
- data/lib/mixin_bot/transfers/model.rb +112 -0
- data/lib/mixin_bot/transfers/performer.rb +64 -0
- data/lib/mixin_bot/transfers/reconcile.rb +53 -0
- data/lib/mixin_bot/transfers.rb +23 -0
- data/lib/mixin_bot/version.rb +1 -1
- data/lib/mixin_bot.rb +4 -0
- data/lib/puma/plugin/mixin_blaze.rb +263 -0
- data/llms.txt +1 -1
- 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
|