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.
- checksums.yaml +4 -4
- data/AGENTS.md +1 -1
- data/API_COVERAGE.md +1 -0
- data/CHANGELOG.md +11 -1
- data/README.md +75 -1
- data/docs/agent/cookbook.md +23 -0
- 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/snapshot.rb +5 -0
- data/lib/mixin_bot/blaze/reactor.rb +8 -3
- 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/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 +3 -0
- data/llms.txt +1 -1
- 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
|
-
|
|
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
|
data/lib/mixin_bot/client.rb
CHANGED
|
@@ -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
|
data/lib/mixin_bot/computer.rb
CHANGED
|
@@ -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
|