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.
@@ -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
@@ -0,0 +1,4 @@
1
+ # lib/bolt_rb/version.rb
2
+ module BoltRb
3
+ VERSION = '0.1.0'
4
+ 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'