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,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'protocol/websocket'
|
|
6
|
+
|
|
7
|
+
module MixinBot
|
|
8
|
+
module Blaze
|
|
9
|
+
# Fiber-based Blaze receive loop — the hosted sibling of
|
|
10
|
+
# examples/blaze_async.rb.
|
|
11
|
+
#
|
|
12
|
+
# Drives API#blaze_async inside its own Async reactor (created by #run,
|
|
13
|
+
# so it can be called from any plain thread — e.g. a Puma plugin's
|
|
14
|
+
# background thread or a forked child). The loop: connect, request
|
|
15
|
+
# pending messages, read frames serially, dispatch each decoded envelope
|
|
16
|
+
# to the configured handler, and acknowledge per the ack policy. Receipt
|
|
17
|
+
# confirmations (ACKNOWLEDGE_MESSAGE_RECEIPT frames) are consumed
|
|
18
|
+
# internally — never dispatched to the handler nor re-acknowledged.
|
|
19
|
+
# Dropped connections are re-established with bounded exponential backoff.
|
|
20
|
+
#
|
|
21
|
+
# Handlers run serially on the reactor thread — never in concurrent
|
|
22
|
+
# fibers — so handler code may use per-thread resources like ActiveRecord
|
|
23
|
+
# connections safely. Handler exceptions are logged and do not end
|
|
24
|
+
# message delivery.
|
|
25
|
+
#
|
|
26
|
+
# Reactor.new(handler: ->(envelope) { ... }).run # blocks forever
|
|
27
|
+
#
|
|
28
|
+
class Reactor
|
|
29
|
+
CONNECT_TIMEOUT = 10
|
|
30
|
+
KEEPALIVE_INTERVAL = 30
|
|
31
|
+
INITIAL_BACKOFF = 1
|
|
32
|
+
MAX_BACKOFF = 300
|
|
33
|
+
# A connection must live at least this long to count as "stable" and
|
|
34
|
+
# reset the reconnect backoff. The bar sits well above any single
|
|
35
|
+
# backoff step so a session that a gateway throttles at a fixed short
|
|
36
|
+
# lifetime (e.g. closed ~30s after every handshake) never reads as
|
|
37
|
+
# stable — otherwise the loop pins itself at the fastest retry rate
|
|
38
|
+
# and hammers the endpoint indefinitely.
|
|
39
|
+
STABLE_PERIOD = 120
|
|
40
|
+
|
|
41
|
+
attr_reader :handler, :ack_policy
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @param api [MixinBot::API] API used for frame codecs and connecting
|
|
45
|
+
# @param handler [#call] invoked with each decoded message envelope
|
|
46
|
+
# Hash (with +action+ and +data+ keys)
|
|
47
|
+
# @param ack_policy [Symbol] +:on_receipt+ (ack before the handler
|
|
48
|
+
# runs) or +:after_handler+ (ack only after the handler succeeds;
|
|
49
|
+
# failed messages are redelivered on reconnect)
|
|
50
|
+
# @param connection_factory [#call, nil] returns a connected
|
|
51
|
+
# +Async::WebSocket+ connection; defaults to +api.blaze_async+
|
|
52
|
+
# with a +connect_timeout+ connect phase
|
|
53
|
+
# @param keepalive_interval [Numeric] seconds between client pings
|
|
54
|
+
# @param connect_timeout [Numeric] seconds allowed for the connect phase
|
|
55
|
+
# (DNS + TCP + TLS + WebSocket upgrade); established connections are
|
|
56
|
+
# never timed out on read
|
|
57
|
+
# @param sleeper [#call, nil] backoff hook, called with seconds;
|
|
58
|
+
# defaults to +Kernel#sleep+ (inject for tests)
|
|
59
|
+
# @param logger [#call, nil] called with +(level, exception_or_message)+;
|
|
60
|
+
# defaults to +$stderr+
|
|
61
|
+
def initialize(handler:, api: MixinBot.api, ack_policy: MixinBot.config.blaze_ack_policy,
|
|
62
|
+
connection_factory: nil, keepalive_interval: KEEPALIVE_INTERVAL,
|
|
63
|
+
connect_timeout: CONNECT_TIMEOUT, sleeper: nil, logger: nil)
|
|
64
|
+
raise MixinBot::ArgumentError, 'handler must respond to #call' unless handler.respond_to?(:call)
|
|
65
|
+
unless %i[on_receipt after_handler].include?(ack_policy)
|
|
66
|
+
raise MixinBot::ArgumentError, "ack_policy must be :on_receipt or :after_handler, got #{ack_policy.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@api = api
|
|
70
|
+
@handler = handler
|
|
71
|
+
@ack_policy = ack_policy
|
|
72
|
+
# No socket-level timeout: endpoint timeout: would apply to every read
|
|
73
|
+
# (a quiet-but-healthy connection would be killed), not just the
|
|
74
|
+
# connect phase. Liveness is the keepalive ping + the connect-phase
|
|
75
|
+
# timeout applied in #connect!.
|
|
76
|
+
@connection_factory = connection_factory || -> { api.blaze_async }
|
|
77
|
+
@keepalive_interval = keepalive_interval
|
|
78
|
+
@connect_timeout = connect_timeout
|
|
79
|
+
@sleeper = sleeper || ->(seconds) { sleep seconds }
|
|
80
|
+
@logger = logger || ->(level, detail) { warn "[mixin_blaze] #{level}: #{detail}" }
|
|
81
|
+
@guard = Mutex.new
|
|
82
|
+
@stopping = false
|
|
83
|
+
@connection = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Runs the connect/read/reconnect loop, blocking the calling thread
|
|
88
|
+
# until #stop. Yields control to the fiber scheduler while waiting.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def run
|
|
92
|
+
Async do |task|
|
|
93
|
+
@task = task
|
|
94
|
+
backoff = INITIAL_BACKOFF
|
|
95
|
+
|
|
96
|
+
until stopped?
|
|
97
|
+
started_at = monotonic_time
|
|
98
|
+
begin
|
|
99
|
+
run_connection
|
|
100
|
+
backoff = INITIAL_BACKOFF if stable?(started_at)
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
log.call :error, e
|
|
103
|
+
backoff = INITIAL_BACKOFF if stable?(started_at)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
break if stopped?
|
|
107
|
+
|
|
108
|
+
sleep_backoff backoff
|
|
109
|
+
backoff = [(backoff * 2), MAX_BACKOFF].min
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
##
|
|
115
|
+
# Stops the loop and closes the current connection. Safe to call from
|
|
116
|
+
# any thread (including while #run blocks in another one) and safe to
|
|
117
|
+
# call more than once.
|
|
118
|
+
#
|
|
119
|
+
# @return [void]
|
|
120
|
+
def stop
|
|
121
|
+
@guard.synchronize do
|
|
122
|
+
@stopping = true
|
|
123
|
+
begin
|
|
124
|
+
@connection&.close
|
|
125
|
+
rescue StandardError
|
|
126
|
+
# already dead; the read loop will notice on its own
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def stopped?
|
|
134
|
+
@guard.synchronize { @stopping }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# One connection lifetime: connect, keepalive, pending request, read
|
|
138
|
+
# loop. Returns on a clean close; raises on a broken one.
|
|
139
|
+
def run_connection
|
|
140
|
+
return unless connect!
|
|
141
|
+
|
|
142
|
+
log.call :connected, "pid=#{Process.pid}"
|
|
143
|
+
|
|
144
|
+
keepalive = start_keepalive
|
|
145
|
+
send_frame @api.list_pending_message
|
|
146
|
+
|
|
147
|
+
while (message = @connection.read)
|
|
148
|
+
dispatch message
|
|
149
|
+
end
|
|
150
|
+
log.call :closed, 'clean close'
|
|
151
|
+
ensure
|
|
152
|
+
keepalive&.stop
|
|
153
|
+
@connection&.close
|
|
154
|
+
@connection = nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Establishes the connection and registers it under the guard, so a
|
|
158
|
+
# concurrent #stop can always find and close it. The connect phase
|
|
159
|
+
# (DNS + TCP + TLS + WebSocket upgrade) is bounded by #connect_timeout —
|
|
160
|
+
# a hung connect becomes an error the run loop retries, never a
|
|
161
|
+
# permanently stuck loop. Returns nil when the reactor stopped
|
|
162
|
+
# mid-connect; the fresh connection is closed here.
|
|
163
|
+
def connect!
|
|
164
|
+
fresh = @task.with_timeout(@connect_timeout) { @connection_factory.call }
|
|
165
|
+
|
|
166
|
+
@guard.synchronize do
|
|
167
|
+
if @stopping
|
|
168
|
+
begin
|
|
169
|
+
fresh&.close
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
@connection = fresh
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
fresh
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def start_keepalive
|
|
183
|
+
Async do
|
|
184
|
+
loop do
|
|
185
|
+
sleep @keepalive_interval
|
|
186
|
+
@connection&.send_ping
|
|
187
|
+
end
|
|
188
|
+
rescue Protocol::WebSocket::ProtocolError, IOError
|
|
189
|
+
# the connection is gone; the read loop is already winding down
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Serial dispatch — handlers never run in concurrent fibers.
|
|
194
|
+
def dispatch(message)
|
|
195
|
+
raw = decode(message)
|
|
196
|
+
return if raw.nil?
|
|
197
|
+
|
|
198
|
+
# Receipt confirmations echo a message_id the client already knows and
|
|
199
|
+
# are never redelivered by LIST_PENDING_MESSAGES — they are not
|
|
200
|
+
# dispatchable events, and acknowledging one back would only
|
|
201
|
+
# round-trip a no-op frame. Skip them before the ack policy sees them.
|
|
202
|
+
return if raw['action'] == 'ACKNOWLEDGE_MESSAGE_RECEIPT'
|
|
203
|
+
|
|
204
|
+
data = raw['data'].is_a?(Hash) ? raw['data'] : {}
|
|
205
|
+
message_id = data['message_id']
|
|
206
|
+
|
|
207
|
+
send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :on_receipt && message_id
|
|
208
|
+
|
|
209
|
+
handler_ok =
|
|
210
|
+
begin
|
|
211
|
+
handler.call raw
|
|
212
|
+
true
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
log.call :handler_error, e
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :after_handler && handler_ok && message_id
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def decode(message)
|
|
222
|
+
JSON.parse @api.ws_message(message.to_str)
|
|
223
|
+
rescue Zlib::Error, JSON::ParserError => e
|
|
224
|
+
log.call :decode_error, e
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def send_frame(bytes)
|
|
229
|
+
@connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def sleep_backoff(seconds)
|
|
233
|
+
@sleeper.call seconds
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def stable?(started_at)
|
|
237
|
+
monotonic_time - started_at >= STABLE_PERIOD
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def monotonic_time
|
|
241
|
+
Process.clock_gettime Process::CLOCK_MONOTONIC
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def log
|
|
245
|
+
@logger
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -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
|
|
@@ -59,6 +59,17 @@ module MixinBot
|
|
|
59
59
|
].freeze
|
|
60
60
|
attr_accessor(*CONFIGURABLE_ATTRS)
|
|
61
61
|
|
|
62
|
+
# Acknowledgement policies for the Blaze message loop:
|
|
63
|
+
# - +:on_receipt+ (default): ack immediately on receipt, before the handler
|
|
64
|
+
# runs (at-most-once dispatch).
|
|
65
|
+
# - +:after_handler+: ack only after the handler completes without raising
|
|
66
|
+
# (at-least-once dispatch; unacked messages are redelivered on reconnect).
|
|
67
|
+
BLAZE_ACK_POLICIES = %i[on_receipt after_handler].freeze
|
|
68
|
+
|
|
69
|
+
DEFAULT_BLAZE_ACK_POLICY = :on_receipt
|
|
70
|
+
|
|
71
|
+
attr_reader :blaze_handler, :blaze_ack_policy
|
|
72
|
+
|
|
62
73
|
##
|
|
63
74
|
# Initializes a new Configuration instance.
|
|
64
75
|
#
|
|
@@ -96,6 +107,10 @@ module MixinBot
|
|
|
96
107
|
|
|
97
108
|
@debug = kwargs[:debug] || false
|
|
98
109
|
|
|
110
|
+
@blaze_ack_policy = DEFAULT_BLAZE_ACK_POLICY
|
|
111
|
+
self.blaze_handler = kwargs[:blaze_handler]
|
|
112
|
+
self.blaze_ack_policy = kwargs[:blaze_ack_policy]
|
|
113
|
+
|
|
99
114
|
self.session_private_key = kwargs[:session_private_key] || kwargs[:private_key]
|
|
100
115
|
self.server_public_key = kwargs[:server_public_key] || kwargs[:pin_token]
|
|
101
116
|
self.spend_key = kwargs[:spend_key]
|
|
@@ -203,6 +218,43 @@ module MixinBot
|
|
|
203
218
|
end
|
|
204
219
|
end
|
|
205
220
|
|
|
221
|
+
##
|
|
222
|
+
# Sets the callable invoked for each decoded Blaze message envelope.
|
|
223
|
+
#
|
|
224
|
+
# Used by MixinBot::Blaze::Reactor (and the +plugin :mixin_blaze+ Puma
|
|
225
|
+
# plugin) to dispatch messages. The callable receives the full decoded
|
|
226
|
+
# message envelope Hash (with +action+ and +data+ keys).
|
|
227
|
+
#
|
|
228
|
+
# @param callable [#call, nil] the message handler; must respond to #call
|
|
229
|
+
# @raise [ArgumentError] if a non-callable, non-nil value is given
|
|
230
|
+
#
|
|
231
|
+
def blaze_handler=(callable)
|
|
232
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
233
|
+
raise ArgumentError,
|
|
234
|
+
"blaze_handler must respond to #call, got #{callable.inspect}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
@blaze_handler = callable
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
##
|
|
241
|
+
# Sets the Blaze acknowledgement policy.
|
|
242
|
+
#
|
|
243
|
+
# @param policy [Symbol] +:on_receipt+ (default) or +:after_handler+
|
|
244
|
+
# @raise [ArgumentError] for unknown policies
|
|
245
|
+
#
|
|
246
|
+
def blaze_ack_policy=(policy)
|
|
247
|
+
return if policy.nil?
|
|
248
|
+
|
|
249
|
+
policy = policy.to_sym
|
|
250
|
+
unless BLAZE_ACK_POLICIES.include?(policy)
|
|
251
|
+
raise ArgumentError,
|
|
252
|
+
"blaze_ack_policy must be one of #{BLAZE_ACK_POLICIES.join(', ')}, got #{policy.inspect}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
@blaze_ack_policy = policy
|
|
256
|
+
end
|
|
257
|
+
|
|
206
258
|
private
|
|
207
259
|
|
|
208
260
|
def decode_key(key)
|