bolt_rb 0.1.0
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/lib/bolt_rb/app.rb +216 -0
- data/lib/bolt_rb/configuration.rb +44 -0
- data/lib/bolt_rb/context.rb +173 -0
- data/lib/bolt_rb/handlers/action_handler.rb +162 -0
- data/lib/bolt_rb/handlers/base.rb +194 -0
- data/lib/bolt_rb/handlers/command_handler.rb +119 -0
- data/lib/bolt_rb/handlers/event_handler.rb +113 -0
- data/lib/bolt_rb/handlers/shortcut_handler.rb +132 -0
- data/lib/bolt_rb/handlers/view_submission_handler.rb +159 -0
- data/lib/bolt_rb/middleware/base.rb +35 -0
- data/lib/bolt_rb/middleware/chain.rb +58 -0
- data/lib/bolt_rb/middleware/logging.rb +60 -0
- data/lib/bolt_rb/router.rb +75 -0
- data/lib/bolt_rb/socket_mode/client.rb +296 -0
- data/lib/bolt_rb/testing/payload_factory.rb +143 -0
- data/lib/bolt_rb/testing/rspec_helpers.rb +62 -0
- data/lib/bolt_rb/testing.rb +20 -0
- data/lib/bolt_rb/version.rb +4 -0
- data/lib/bolt_rb.rb +76 -0
- metadata +149 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'websocket-client-simple'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module BoltRb
|
|
9
|
+
module SocketMode
|
|
10
|
+
# WebSocket client for Slack Socket Mode connections
|
|
11
|
+
#
|
|
12
|
+
# Handles the WebSocket lifecycle including:
|
|
13
|
+
# - Obtaining a connection URL via apps.connections.open
|
|
14
|
+
# - Establishing and maintaining the WebSocket connection
|
|
15
|
+
# - Acknowledging received events
|
|
16
|
+
# - Automatic reconnection on disconnect
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage
|
|
19
|
+
# client = BoltRb::SocketMode::Client.new(
|
|
20
|
+
# app_token: 'xapp-...',
|
|
21
|
+
# logger: Logger.new(STDOUT)
|
|
22
|
+
# )
|
|
23
|
+
# client.on_message { |payload| handle_event(payload) }
|
|
24
|
+
# client.start
|
|
25
|
+
class Client
|
|
26
|
+
SLACK_API_URL = 'https://slack.com/api/apps.connections.open'
|
|
27
|
+
RECONNECT_DELAY = 5
|
|
28
|
+
|
|
29
|
+
# @return [String] The Slack app-level token
|
|
30
|
+
attr_reader :app_token
|
|
31
|
+
|
|
32
|
+
# @return [Logger] The logger instance
|
|
33
|
+
attr_reader :logger
|
|
34
|
+
|
|
35
|
+
# Creates a new Socket Mode client
|
|
36
|
+
#
|
|
37
|
+
# @param app_token [String] The Slack app-level token (xapp-...)
|
|
38
|
+
# @param logger [Logger] Logger instance for output
|
|
39
|
+
def initialize(app_token:, logger: nil)
|
|
40
|
+
@app_token = app_token
|
|
41
|
+
@logger = logger || Logger.new($stdout)
|
|
42
|
+
@running = false
|
|
43
|
+
@websocket = nil
|
|
44
|
+
@message_handlers = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Registers a handler for incoming messages
|
|
48
|
+
#
|
|
49
|
+
# @yield [Hash] The parsed event payload
|
|
50
|
+
# @return [void]
|
|
51
|
+
def on_message(&block)
|
|
52
|
+
@message_handlers << block
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Starts the Socket Mode connection
|
|
56
|
+
#
|
|
57
|
+
# Obtains a WebSocket URL and establishes the connection.
|
|
58
|
+
# This method blocks until stop is called.
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
def start
|
|
62
|
+
@running = true
|
|
63
|
+
connect_with_retry
|
|
64
|
+
run_loop
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Stops the Socket Mode connection
|
|
68
|
+
#
|
|
69
|
+
# @return [void]
|
|
70
|
+
def stop
|
|
71
|
+
@running = false
|
|
72
|
+
@websocket&.close
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Requests a stop - safe to call from trap context
|
|
76
|
+
#
|
|
77
|
+
# Only sets the running flag to false. Does NOT close the websocket
|
|
78
|
+
# or perform any operations that might use mutexes, as this is
|
|
79
|
+
# designed to be called from signal trap handlers.
|
|
80
|
+
#
|
|
81
|
+
# @return [void]
|
|
82
|
+
def request_stop
|
|
83
|
+
@running = false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Boolean] Whether the client is currently running
|
|
87
|
+
def running?
|
|
88
|
+
@running
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Boolean] Whether the WebSocket is connected
|
|
92
|
+
def connected?
|
|
93
|
+
@websocket&.open?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Main run loop that keeps the connection alive
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
def run_loop
|
|
102
|
+
while @running
|
|
103
|
+
sleep 0.1
|
|
104
|
+
reconnect_if_needed
|
|
105
|
+
end
|
|
106
|
+
ensure
|
|
107
|
+
# Clean up websocket when loop exits
|
|
108
|
+
@websocket&.close
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Reconnects if the WebSocket is disconnected
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
def reconnect_if_needed
|
|
115
|
+
return if !@running || connected?
|
|
116
|
+
|
|
117
|
+
logger.info '[SocketMode] Connection lost, reconnecting...'
|
|
118
|
+
sleep RECONNECT_DELAY
|
|
119
|
+
connect_with_retry
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Attempts to connect with retry logic
|
|
123
|
+
#
|
|
124
|
+
# @return [void]
|
|
125
|
+
def connect_with_retry
|
|
126
|
+
retries = 0
|
|
127
|
+
max_retries = 5
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
connect
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
retries += 1
|
|
133
|
+
if retries <= max_retries
|
|
134
|
+
logger.warn "[SocketMode] Connection failed (attempt #{retries}/#{max_retries}): #{e.message}"
|
|
135
|
+
sleep RECONNECT_DELAY
|
|
136
|
+
retry
|
|
137
|
+
else
|
|
138
|
+
logger.error "[SocketMode] Max retries exceeded, giving up: #{e.message}"
|
|
139
|
+
@running = false
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Establishes the WebSocket connection
|
|
145
|
+
#
|
|
146
|
+
# @return [void]
|
|
147
|
+
def connect
|
|
148
|
+
url = obtain_websocket_url
|
|
149
|
+
logger.info '[SocketMode] Connecting to Slack...'
|
|
150
|
+
|
|
151
|
+
client = self
|
|
152
|
+
@websocket = WebSocket::Client::Simple.connect(url)
|
|
153
|
+
|
|
154
|
+
@websocket.on :open do
|
|
155
|
+
client.send(:handle_open)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
@websocket.on :message do |msg|
|
|
159
|
+
client.send(:handle_message, msg)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@websocket.on :error do |e|
|
|
163
|
+
client.send(:handle_error, e)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@websocket.on :close do |e|
|
|
167
|
+
client.send(:handle_close, e)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Wait for connection to establish
|
|
171
|
+
sleep 0.5 until @websocket.open? || !@running
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Obtains a WebSocket URL from Slack
|
|
175
|
+
#
|
|
176
|
+
# @return [String] The WebSocket URL
|
|
177
|
+
# @raise [RuntimeError] If unable to obtain URL
|
|
178
|
+
def obtain_websocket_url
|
|
179
|
+
uri = URI.parse(SLACK_API_URL)
|
|
180
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
181
|
+
http.use_ssl = true
|
|
182
|
+
|
|
183
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
184
|
+
request['Authorization'] = "Bearer #{app_token}"
|
|
185
|
+
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
186
|
+
|
|
187
|
+
response = http.request(request)
|
|
188
|
+
body = JSON.parse(response.body)
|
|
189
|
+
|
|
190
|
+
unless body['ok']
|
|
191
|
+
raise "Failed to obtain WebSocket URL: #{body['error']}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
body['url']
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Handles WebSocket open event
|
|
198
|
+
#
|
|
199
|
+
# @return [void]
|
|
200
|
+
def handle_open
|
|
201
|
+
logger.info '[SocketMode] Connected to Slack'
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Handles incoming WebSocket message
|
|
205
|
+
#
|
|
206
|
+
# @param msg [WebSocket::Client::Simple::Message] The message
|
|
207
|
+
# @return [void]
|
|
208
|
+
def handle_message(msg)
|
|
209
|
+
# Skip nil, empty, or non-JSON data (like WebSocket ping/pong frames)
|
|
210
|
+
return if msg.data.nil? || msg.data.empty? || !msg.data.start_with?('{')
|
|
211
|
+
|
|
212
|
+
data = JSON.parse(msg.data)
|
|
213
|
+
|
|
214
|
+
# Handle Slack's control messages
|
|
215
|
+
case data['type']
|
|
216
|
+
when 'hello'
|
|
217
|
+
logger.debug '[SocketMode] Received hello from Slack'
|
|
218
|
+
return
|
|
219
|
+
when 'disconnect'
|
|
220
|
+
logger.info "[SocketMode] Disconnect requested: #{data['reason']}"
|
|
221
|
+
@websocket&.close
|
|
222
|
+
return
|
|
223
|
+
when 'ping'
|
|
224
|
+
handle_ping(data)
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Acknowledge the event
|
|
229
|
+
acknowledge(data['envelope_id']) if data['envelope_id']
|
|
230
|
+
|
|
231
|
+
# Dispatch to handlers
|
|
232
|
+
dispatch_event(data)
|
|
233
|
+
rescue JSON::ParserError => e
|
|
234
|
+
logger.error "[SocketMode] Failed to parse message: #{e.message}"
|
|
235
|
+
rescue StandardError => e
|
|
236
|
+
logger.error "[SocketMode] Error handling message: #{e.message}"
|
|
237
|
+
logger.error e.backtrace.first(5).join("\n")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Handles WebSocket error
|
|
241
|
+
#
|
|
242
|
+
# @param error [Exception] The error
|
|
243
|
+
# @return [void]
|
|
244
|
+
def handle_error(error)
|
|
245
|
+
logger.error "[SocketMode] WebSocket error: #{error.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Handles WebSocket close
|
|
249
|
+
#
|
|
250
|
+
# @param event [Object] The close event
|
|
251
|
+
# @return [void]
|
|
252
|
+
def handle_close(event)
|
|
253
|
+
logger.info "[SocketMode] WebSocket closed: #{event}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Handles Slack Socket Mode ping message
|
|
257
|
+
#
|
|
258
|
+
# Responds with a pong message echoing back the num field
|
|
259
|
+
# @param data [Hash] The ping message data
|
|
260
|
+
# @return [void]
|
|
261
|
+
def handle_ping(data)
|
|
262
|
+
return unless @websocket&.open?
|
|
263
|
+
|
|
264
|
+
pong = { 'type' => 'pong' }
|
|
265
|
+
pong['num'] = data['num'] if data['num']
|
|
266
|
+
@websocket.send(pong.to_json)
|
|
267
|
+
logger.debug "[SocketMode] Responded to ping#{data['num'] ? " (num: #{data['num']})" : ''}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Sends an acknowledgement for an event
|
|
271
|
+
#
|
|
272
|
+
# @param envelope_id [String] The envelope ID to acknowledge
|
|
273
|
+
# @return [void]
|
|
274
|
+
def acknowledge(envelope_id)
|
|
275
|
+
return unless @websocket&.open?
|
|
276
|
+
|
|
277
|
+
ack = { envelope_id: envelope_id }.to_json
|
|
278
|
+
@websocket.send(ack)
|
|
279
|
+
logger.debug "[SocketMode] Acknowledged envelope: #{envelope_id}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Dispatches an event to registered handlers
|
|
283
|
+
#
|
|
284
|
+
# @param data [Hash] The event data
|
|
285
|
+
# @return [void]
|
|
286
|
+
def dispatch_event(data)
|
|
287
|
+
@message_handlers.each do |handler|
|
|
288
|
+
handler.call(data)
|
|
289
|
+
rescue StandardError => e
|
|
290
|
+
logger.error "[SocketMode] Handler error: #{e.message}"
|
|
291
|
+
logger.error e.backtrace.first(5).join("\n")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module BoltRb
|
|
6
|
+
module Testing
|
|
7
|
+
# Factory class for creating fake Slack payloads for testing purposes.
|
|
8
|
+
#
|
|
9
|
+
# This class provides methods to generate realistic payloads for various
|
|
10
|
+
# Slack event types, making it easy to write specs for handlers without
|
|
11
|
+
# needing actual Slack events.
|
|
12
|
+
#
|
|
13
|
+
# @example Creating a message event payload
|
|
14
|
+
# payload = BoltRb::Testing::PayloadFactory.message(text: 'hello')
|
|
15
|
+
# # => { 'type' => 'event_callback', 'event' => { 'type' => 'message', ... } }
|
|
16
|
+
#
|
|
17
|
+
# @example Creating a slash command payload
|
|
18
|
+
# payload = BoltRb::Testing::PayloadFactory.command(command: '/deploy', text: 'production')
|
|
19
|
+
# # => { 'command' => '/deploy', 'text' => 'production', ... }
|
|
20
|
+
class PayloadFactory
|
|
21
|
+
class << self
|
|
22
|
+
# Creates a message event payload
|
|
23
|
+
#
|
|
24
|
+
# @param text [String] The message text
|
|
25
|
+
# @param user [String] The user ID (default: 'U123TEST')
|
|
26
|
+
# @param channel [String] The channel ID (default: 'C456TEST')
|
|
27
|
+
# @param ts [String, nil] The timestamp (auto-generated if nil)
|
|
28
|
+
# @param thread_ts [String, nil] The thread timestamp for threaded messages
|
|
29
|
+
# @return [Hash] The message event payload
|
|
30
|
+
def message(text:, user: 'U123TEST', channel: 'C456TEST', ts: nil, thread_ts: nil)
|
|
31
|
+
{
|
|
32
|
+
'type' => 'event_callback',
|
|
33
|
+
'event' => {
|
|
34
|
+
'type' => 'message',
|
|
35
|
+
'text' => text,
|
|
36
|
+
'user' => user,
|
|
37
|
+
'channel' => channel,
|
|
38
|
+
'ts' => ts || generate_ts,
|
|
39
|
+
'thread_ts' => thread_ts
|
|
40
|
+
}.compact
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Creates an app_mention event payload
|
|
45
|
+
#
|
|
46
|
+
# @param text [String] The mention text (should include the bot mention)
|
|
47
|
+
# @param user [String] The user ID (default: 'U123TEST')
|
|
48
|
+
# @param channel [String] The channel ID (default: 'C456TEST')
|
|
49
|
+
# @return [Hash] The app_mention event payload
|
|
50
|
+
def app_mention(text:, user: 'U123TEST', channel: 'C456TEST')
|
|
51
|
+
{
|
|
52
|
+
'type' => 'event_callback',
|
|
53
|
+
'event' => {
|
|
54
|
+
'type' => 'app_mention',
|
|
55
|
+
'text' => text,
|
|
56
|
+
'user' => user,
|
|
57
|
+
'channel' => channel,
|
|
58
|
+
'ts' => generate_ts
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Creates a slash command payload
|
|
64
|
+
#
|
|
65
|
+
# @param command [String] The command name (e.g., '/deploy')
|
|
66
|
+
# @param text [String] The text after the command (default: '')
|
|
67
|
+
# @param user [String] The user ID (default: 'U123TEST')
|
|
68
|
+
# @param channel [String] The channel ID (default: 'C456TEST')
|
|
69
|
+
# @return [Hash] The command payload
|
|
70
|
+
def command(command:, text: '', user: 'U123TEST', channel: 'C456TEST')
|
|
71
|
+
{
|
|
72
|
+
'command' => command,
|
|
73
|
+
'text' => text,
|
|
74
|
+
'user_id' => user,
|
|
75
|
+
'channel_id' => channel,
|
|
76
|
+
'response_url' => 'https://hooks.slack.com/commands/T123/456/xxx',
|
|
77
|
+
'trigger_id' => "trigger_#{SecureRandom.hex(8)}"
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Creates a block_actions payload for interactive components
|
|
82
|
+
#
|
|
83
|
+
# @param action_id [String] The action ID of the interactive component
|
|
84
|
+
# @param value [String, nil] The value of the action
|
|
85
|
+
# @param user [String] The user ID (default: 'U123TEST')
|
|
86
|
+
# @param block_id [String, nil] The block ID (default: 'block_1')
|
|
87
|
+
# @param channel [String] The channel ID (default: 'C456TEST')
|
|
88
|
+
# @return [Hash] The block_actions payload
|
|
89
|
+
def action(action_id:, value: nil, user: 'U123TEST', block_id: nil, channel: 'C456TEST')
|
|
90
|
+
{
|
|
91
|
+
'type' => 'block_actions',
|
|
92
|
+
'user' => { 'id' => user },
|
|
93
|
+
'channel' => { 'id' => channel },
|
|
94
|
+
'actions' => [{
|
|
95
|
+
'action_id' => action_id,
|
|
96
|
+
'block_id' => block_id || 'block_1',
|
|
97
|
+
'value' => value
|
|
98
|
+
}.compact],
|
|
99
|
+
'response_url' => 'https://hooks.slack.com/actions/T123/456/xxx',
|
|
100
|
+
'trigger_id' => "trigger_#{SecureRandom.hex(8)}"
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Creates a shortcut payload (global or message)
|
|
105
|
+
#
|
|
106
|
+
# @param callback_id [String] The callback ID of the shortcut
|
|
107
|
+
# @param user [String] The user ID (default: 'U123TEST')
|
|
108
|
+
# @param type [Symbol] The shortcut type (:global or :message)
|
|
109
|
+
# @param message_text [String, nil] The message text for message shortcuts
|
|
110
|
+
# @return [Hash] The shortcut payload
|
|
111
|
+
def shortcut(callback_id:, user: 'U123TEST', type: :global, message_text: nil)
|
|
112
|
+
payload = {
|
|
113
|
+
'type' => type == :message ? 'message_action' : 'shortcut',
|
|
114
|
+
'callback_id' => callback_id,
|
|
115
|
+
'user' => { 'id' => user },
|
|
116
|
+
'trigger_id' => "trigger_#{SecureRandom.hex(8)}"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if type == :message
|
|
120
|
+
payload['channel'] = { 'id' => 'C456TEST' }
|
|
121
|
+
payload['message'] = {
|
|
122
|
+
'type' => 'message',
|
|
123
|
+
'text' => message_text || 'Original message',
|
|
124
|
+
'user' => 'U789MSG',
|
|
125
|
+
'ts' => generate_ts
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
payload
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Generates a fake Slack timestamp
|
|
135
|
+
#
|
|
136
|
+
# @return [String] A timestamp in Slack's format (epoch.random)
|
|
137
|
+
def generate_ts
|
|
138
|
+
"#{Time.now.to_i}.#{SecureRandom.hex(3)}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BoltRb
|
|
4
|
+
module Testing
|
|
5
|
+
# RSpec helper methods for testing Bolt handlers
|
|
6
|
+
#
|
|
7
|
+
# Include this module in your RSpec configuration to get convenient
|
|
8
|
+
# methods for building contexts and mocking Slack clients.
|
|
9
|
+
#
|
|
10
|
+
# @example Including in RSpec
|
|
11
|
+
# RSpec.configure do |config|
|
|
12
|
+
# config.include BoltRb::Testing::RSpecHelpers
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example Using in specs
|
|
16
|
+
# describe MyHandler do
|
|
17
|
+
# it 'handles messages' do
|
|
18
|
+
# ctx = build_context(payload.message(text: 'hello'))
|
|
19
|
+
# MyHandler.call(ctx)
|
|
20
|
+
# expect(ctx.acked?).to be true
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module RSpecHelpers
|
|
24
|
+
# Builds a Context object from a payload for testing
|
|
25
|
+
#
|
|
26
|
+
# @param payload [Hash] The Slack event payload
|
|
27
|
+
# @param client [Object, nil] The Slack client (mocked if nil)
|
|
28
|
+
# @param ack [Proc, nil] The acknowledgement function (no-op if nil)
|
|
29
|
+
# @return [BoltRb::Context] The context object
|
|
30
|
+
def build_context(payload, client: nil, ack: nil)
|
|
31
|
+
BoltRb::Context.new(
|
|
32
|
+
payload: payload,
|
|
33
|
+
client: client || mock_slack_client,
|
|
34
|
+
ack: ack || ->(_) {}
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Creates a mock Slack Web API client with common methods stubbed
|
|
39
|
+
#
|
|
40
|
+
# @return [RSpec::Mocks::Double] A mock Slack client
|
|
41
|
+
def mock_slack_client
|
|
42
|
+
client = instance_double(Slack::Web::Client)
|
|
43
|
+
allow(client).to receive(:chat_postMessage).and_return({ 'ok' => true, 'ts' => '123.456' })
|
|
44
|
+
allow(client).to receive(:chat_update).and_return({ 'ok' => true })
|
|
45
|
+
allow(client).to receive(:views_open).and_return({ 'ok' => true })
|
|
46
|
+
allow(client).to receive(:views_update).and_return({ 'ok' => true })
|
|
47
|
+
client
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convenience accessor for the PayloadFactory
|
|
51
|
+
#
|
|
52
|
+
# @return [Class] The PayloadFactory class
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# payload.message(text: 'hello')
|
|
56
|
+
# payload.command(command: '/deploy')
|
|
57
|
+
def payload
|
|
58
|
+
BoltRb::Testing::PayloadFactory
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'testing/payload_factory'
|
|
4
|
+
require_relative 'testing/rspec_helpers'
|
|
5
|
+
|
|
6
|
+
module BoltRb
|
|
7
|
+
# Testing utilities for BoltRb applications
|
|
8
|
+
#
|
|
9
|
+
# This module provides helpers for writing tests for Slack handlers,
|
|
10
|
+
# including payload factories and RSpec integration.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic setup in spec_helper.rb
|
|
13
|
+
# require 'bolt_rb/testing'
|
|
14
|
+
#
|
|
15
|
+
# RSpec.configure do |config|
|
|
16
|
+
# config.include BoltRb::Testing::RSpecHelpers
|
|
17
|
+
# end
|
|
18
|
+
module Testing
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/bolt_rb.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
# Define the module and core methods first so they're available during handler loading
|
|
6
|
+
module BoltRb
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Returns the current configuration instance
|
|
12
|
+
#
|
|
13
|
+
# @return [Configuration] The memoized configuration instance
|
|
14
|
+
def configuration
|
|
15
|
+
@configuration ||= Configuration.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Yields the configuration for block-style configuration
|
|
19
|
+
#
|
|
20
|
+
# @yield [Configuration] The configuration instance
|
|
21
|
+
# @example
|
|
22
|
+
# BoltRb.configure do |config|
|
|
23
|
+
# config.bot_token = 'xoxb-...'
|
|
24
|
+
# end
|
|
25
|
+
def configure
|
|
26
|
+
yield(configuration)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Resets the configuration to a fresh instance
|
|
30
|
+
# Useful for testing or reconfiguration scenarios
|
|
31
|
+
#
|
|
32
|
+
# @return [Configuration] The new configuration instance
|
|
33
|
+
def reset_configuration!
|
|
34
|
+
@configuration = Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convenience accessor for the logger
|
|
38
|
+
#
|
|
39
|
+
# @return [Logger] The configured logger instance
|
|
40
|
+
def logger
|
|
41
|
+
configuration.logger
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the global router instance
|
|
45
|
+
#
|
|
46
|
+
# @return [Router] The memoized router instance
|
|
47
|
+
def router
|
|
48
|
+
@router ||= Router.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resets the router to a fresh instance
|
|
52
|
+
# Useful for testing or reconfiguration scenarios
|
|
53
|
+
#
|
|
54
|
+
# @return [Router] The new router instance
|
|
55
|
+
def reset_router!
|
|
56
|
+
@router = Router.new
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
require_relative 'bolt_rb/version'
|
|
62
|
+
require_relative 'bolt_rb/middleware/base'
|
|
63
|
+
require_relative 'bolt_rb/middleware/chain'
|
|
64
|
+
require_relative 'bolt_rb/middleware/logging'
|
|
65
|
+
require_relative 'bolt_rb/configuration'
|
|
66
|
+
require_relative 'bolt_rb/context'
|
|
67
|
+
require_relative 'bolt_rb/router'
|
|
68
|
+
require_relative 'bolt_rb/handlers/base'
|
|
69
|
+
require_relative 'bolt_rb/handlers/event_handler'
|
|
70
|
+
require_relative 'bolt_rb/handlers/command_handler'
|
|
71
|
+
require_relative 'bolt_rb/handlers/action_handler'
|
|
72
|
+
require_relative 'bolt_rb/handlers/shortcut_handler'
|
|
73
|
+
require_relative 'bolt_rb/handlers/view_submission_handler'
|
|
74
|
+
require_relative 'bolt_rb/socket_mode/client'
|
|
75
|
+
require_relative 'bolt_rb/testing'
|
|
76
|
+
require_relative 'bolt_rb/app'
|