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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9935362013533090530a02a62c08ba8705541780b5d859bf0dcbd1ea84a1dbe2
4
+ data.tar.gz: 9de74bd3e31abe9fea99e980803e4c649c520da33e73638ad11db0ed87eb8fd9
5
+ SHA512:
6
+ metadata.gz: 9df04458ee8addd0827a52cf7e00e69a96da0acf04e928343ce0b67a9d47f446896ac32d8bf83af692d19eac81599ba63e5e165411de9e931ad343ad4fd97fba
7
+ data.tar.gz: 52fb0f69a9eb711740c6194ce2a3d1125cf85ad76f942fd8f211bcd16daab3a8678af5f87c0350db5d9b3aae1ee85a5a98ca5580ae4ec550dc40c66091971c03
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jon Whitcraft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # bolt-rb
2
+
3
+ > **Note:** This project is provided as-is with no active support. I'll add features when I need them and accept PRs if someone wants to contribute fixes. Use at your own risk.
4
+
5
+ A [bolt-js](https://slack.dev/bolt-js) inspired framework for building Slack bots in Ruby using Socket Mode.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem 'bolt_rb'
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```ruby
24
+ require 'bolt_rb'
25
+
26
+ BoltRb.configure do |config|
27
+ config.bot_token = ENV.fetch('SLACK_BOT_TOKEN')
28
+ config.app_token = ENV.fetch('SLACK_APP_TOKEN')
29
+ config.handler_paths = ['./handlers']
30
+ end
31
+
32
+ app = BoltRb::App.new
33
+
34
+ # Graceful shutdown
35
+ %w[INT TERM].each do |signal|
36
+ Signal.trap(signal) { app.request_stop }
37
+ end
38
+
39
+ app.start
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ | Option | Description |
45
+ |--------|-------------|
46
+ | `bot_token` | Your Slack bot token (`xoxb-...`) |
47
+ | `app_token` | Your Slack app-level token (`xapp-...`) for Socket Mode |
48
+ | `handler_paths` | Array of directories to load handlers from |
49
+
50
+ ## Handlers
51
+
52
+ Handlers are auto-registered when loaded. Just drop them in your handler paths.
53
+
54
+ ### Events
55
+
56
+ Listen to Slack events like messages, reactions, app mentions:
57
+
58
+ ```ruby
59
+ class GreetingHandler < BoltRb::EventHandler
60
+ listen_to :message, pattern: /hello/i
61
+
62
+ def handle
63
+ say "Hey there <@#{user}>!"
64
+ end
65
+ end
66
+ ```
67
+
68
+ ```ruby
69
+ class MentionHandler < BoltRb::EventHandler
70
+ listen_to :app_mention
71
+
72
+ def handle
73
+ say "You rang?"
74
+ end
75
+ end
76
+ ```
77
+
78
+ **Available methods:** `event`, `text`, `thread_ts`, `ts`, `user`, `channel`, `say`, `client`
79
+
80
+ ### Slash Commands
81
+
82
+ Handle slash commands like `/deploy`:
83
+
84
+ ```ruby
85
+ class DeployCommand < BoltRb::CommandHandler
86
+ command '/deploy'
87
+
88
+ def handle
89
+ ack "Deploying #{command_text}..."
90
+ # Do the work
91
+ say "Deployed #{command_text} successfully!"
92
+ end
93
+ end
94
+ ```
95
+
96
+ **Available methods:** `command_name`, `command_text`, `trigger_id`, `user`, `channel`, `ack`, `say`, `respond`, `client`
97
+
98
+ ### Actions
99
+
100
+ Handle button clicks, select menus, and other interactive components:
101
+
102
+ ```ruby
103
+ class ApproveHandler < BoltRb::ActionHandler
104
+ action 'approve_button'
105
+
106
+ def handle
107
+ ack
108
+ say "Approved by <@#{user}>!"
109
+ end
110
+ end
111
+ ```
112
+
113
+ Supports regex matching:
114
+
115
+ ```ruby
116
+ class DynamicButtonHandler < BoltRb::ActionHandler
117
+ action /^approve_request_/
118
+
119
+ def handle
120
+ ack
121
+ request_id = action_id.gsub('approve_request_', '')
122
+ # Process the request
123
+ end
124
+ end
125
+ ```
126
+
127
+ **Available methods:** `action`, `action_id`, `action_value`, `block_id`, `trigger_id`, `user`, `channel`, `ack`, `say`, `respond`, `client`
128
+
129
+ ### Shortcuts
130
+
131
+ Handle global shortcuts (lightning bolt menu) and message shortcuts:
132
+
133
+ ```ruby
134
+ class CreateTicketHandler < BoltRb::ShortcutHandler
135
+ shortcut 'create_ticket'
136
+
137
+ def handle
138
+ ack
139
+ client.views_open(
140
+ trigger_id: trigger_id,
141
+ view: { type: 'modal', title: { type: 'plain_text', text: 'Create Ticket' }, ... }
142
+ )
143
+ end
144
+ end
145
+ ```
146
+
147
+ **Available methods:** `callback_id`, `trigger_id`, `shortcut_type`, `message`, `message_text`, `user`, `channel`, `ack`, `client`
148
+
149
+ ### View Submissions
150
+
151
+ Handle modal form submissions:
152
+
153
+ ```ruby
154
+ class TicketSubmitHandler < BoltRb::ViewSubmissionHandler
155
+ view 'create_ticket_modal'
156
+
157
+ def handle
158
+ title = values.dig('title_block', 'title_input', 'value')
159
+
160
+ if title.nil? || title.empty?
161
+ ack(response_action: 'errors', errors: { 'title_block' => 'Title is required' })
162
+ else
163
+ ack
164
+ say "Created ticket: #{title}"
165
+ end
166
+ end
167
+ end
168
+ ```
169
+
170
+ **Available methods:** `view`, `callback_id`, `private_metadata`, `values`, `view_hash`, `response_urls`, `user_id`, `ack`, `say`, `client`
171
+
172
+ ## Handler Methods
173
+
174
+ All handlers have access to:
175
+
176
+ | Method | Description |
177
+ |--------|-------------|
178
+ | `say(message)` | Post a message to the channel |
179
+ | `ack(response)` | Acknowledge the event (required for commands, actions, shortcuts, views) |
180
+ | `respond(message)` | Send a response using the response_url |
181
+ | `client` | The `Slack::Web::Client` for API calls |
182
+ | `payload` | The raw Slack payload |
183
+ | `user` | The user ID who triggered the event |
184
+ | `channel` | The channel ID |
185
+
186
+ ## Middleware
187
+
188
+ Add handler-specific middleware:
189
+
190
+ ```ruby
191
+ class ProtectedHandler < BoltRb::CommandHandler
192
+ command '/admin'
193
+ use AdminOnlyMiddleware
194
+
195
+ def handle
196
+ # Only admins get here
197
+ end
198
+ end
199
+ ```
200
+
201
+ ## Slack App Setup
202
+
203
+ 1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
204
+ 2. Enable **Socket Mode** under Settings
205
+ 3. Generate an **App-Level Token** with `connections:write` scope
206
+ 4. Add a **Bot Token** with the scopes you need (e.g., `chat:write`, `commands`)
207
+ 5. Install the app to your workspace
208
+
209
+ ## Development
210
+
211
+ ```bash
212
+ bundle install
213
+ bundle exec rspec
214
+ ```
215
+
216
+ ## License
217
+
218
+ MIT
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'slack-ruby-client'
4
+
5
+ module BoltRb
6
+ # Main application class for running a Bolt app with Socket Mode
7
+ #
8
+ # This class initializes the Slack clients, loads handlers, and processes
9
+ # incoming events through the middleware chain and router.
10
+ #
11
+ # @example Basic usage
12
+ # BoltRb.configure do |config|
13
+ # config.bot_token = ENV['SLACK_BOT_TOKEN']
14
+ # config.app_token = ENV['SLACK_APP_TOKEN']
15
+ # end
16
+ #
17
+ # app = BoltRb::App.new
18
+ # app.start
19
+ #
20
+ # @example With custom handler paths
21
+ # BoltRb.configure do |config|
22
+ # config.bot_token = ENV['SLACK_BOT_TOKEN']
23
+ # config.app_token = ENV['SLACK_APP_TOKEN']
24
+ # config.handler_paths = ['lib/handlers']
25
+ # end
26
+ #
27
+ # app = BoltRb::App.new
28
+ # app.start
29
+ class App
30
+ # @return [Slack::Web::Client] The Slack Web API client
31
+ attr_reader :client
32
+
33
+ # @return [Router] The router instance for dispatching events
34
+ attr_reader :router
35
+
36
+ # @return [Configuration] The configuration instance
37
+ attr_reader :config
38
+
39
+ # @return [SocketMode::Client] The Socket Mode client instance
40
+ attr_reader :socket_client
41
+
42
+ # Creates a new App instance
43
+ #
44
+ # Initializes the Slack Web API client for making API calls
45
+ # and the Socket Mode client for receiving events.
46
+ def initialize
47
+ @config = BoltRb.configuration
48
+ @router = BoltRb.router
49
+ @client = Slack::Web::Client.new(token: config.bot_token)
50
+
51
+ setup_socket_client
52
+ end
53
+
54
+ # Starts the Socket Mode connection
55
+ #
56
+ # Loads all handlers from configured paths and connects to Slack
57
+ # via Socket Mode to start receiving events.
58
+ #
59
+ # @return [void]
60
+ def start
61
+ load_handlers
62
+ BoltRb.logger.info '[BoltRb] Starting app...'
63
+ @socket_client.start
64
+ end
65
+
66
+ # Stops the Socket Mode connection
67
+ #
68
+ # Gracefully disconnects from Slack.
69
+ #
70
+ # @return [void]
71
+ def stop
72
+ BoltRb.logger.info '[BoltRb] Stopping app...'
73
+ @socket_client.stop
74
+ end
75
+
76
+ # Requests a stop - safe to call from trap context
77
+ #
78
+ # Use this in signal handlers instead of stop to avoid
79
+ # ThreadError from calling methods that use mutexes.
80
+ #
81
+ # @return [void]
82
+ def request_stop
83
+ @socket_client.request_stop
84
+ end
85
+
86
+ # @return [Boolean] Whether the app is currently running
87
+ def running?
88
+ @socket_client.running?
89
+ end
90
+
91
+ # Processes an incoming event payload
92
+ #
93
+ # Routes the event to matching handlers and executes them through
94
+ # the middleware chain. Errors in individual handlers are caught
95
+ # and passed to the configured error handler without stopping
96
+ # other handlers from executing.
97
+ #
98
+ # @param payload [Hash] The incoming Slack event payload
99
+ # @return [void]
100
+ def process_event(payload)
101
+ handlers = router.route(payload)
102
+ return if handlers.empty?
103
+
104
+ context = build_context(payload)
105
+
106
+ Middleware::Chain.new(config.middleware).call(context) do
107
+ handlers.each do |handler_class|
108
+ execute_handler(handler_class, context)
109
+ end
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # Sets up the Socket Mode client with event handling
116
+ #
117
+ # @return [void]
118
+ def setup_socket_client
119
+ @socket_client = SocketMode::Client.new(
120
+ app_token: config.app_token,
121
+ logger: BoltRb.logger
122
+ )
123
+
124
+ @socket_client.on_message do |data|
125
+ handle_socket_event(data)
126
+ end
127
+ end
128
+
129
+ # Handles incoming Socket Mode events
130
+ #
131
+ # Extracts the payload from the Socket Mode envelope and routes it
132
+ # to the appropriate handlers.
133
+ #
134
+ # @param data [Hash] The Socket Mode envelope data
135
+ # @return [void]
136
+ def handle_socket_event(data)
137
+ payload = extract_payload(data)
138
+ process_event(payload) if payload
139
+ rescue StandardError => e
140
+ BoltRb.logger.error "[BoltRb] Error handling socket event: #{e.message}"
141
+ BoltRb.logger.error e.backtrace.first(5).join("\n")
142
+ end
143
+
144
+ # Extracts the event payload from a Socket Mode envelope
145
+ #
146
+ # @param data [Hash] The Socket Mode envelope
147
+ # @return [Hash, nil] The extracted payload or nil if not processable
148
+ def extract_payload(data)
149
+ case data['type']
150
+ when 'events_api'
151
+ data['payload']
152
+ when 'interactive', 'slash_commands', 'block_actions', 'view_submission', 'view_closed', 'shortcut'
153
+ data['payload']
154
+ else
155
+ # For unknown types, pass through the whole data
156
+ data
157
+ end
158
+ end
159
+
160
+ # Loads all handler files from configured paths
161
+ #
162
+ # @return [void]
163
+ def load_handlers
164
+ config.handler_paths.each do |path|
165
+ pattern = File.join(path, '**', '*.rb')
166
+ Dir.glob(pattern).sort.each do |file|
167
+ require file
168
+ end
169
+ end
170
+
171
+ BoltRb.logger.info "[BoltRb] Loaded #{router.handler_count} handlers"
172
+ end
173
+
174
+ # Builds a Context object for the given payload
175
+ #
176
+ # @param payload [Hash] The event payload
177
+ # @return [Context] The context for handler execution
178
+ def build_context(payload)
179
+ Context.new(
180
+ payload: payload,
181
+ client: client,
182
+ ack: build_ack_fn(payload)
183
+ )
184
+ end
185
+
186
+ # Builds the acknowledgement function for a payload
187
+ #
188
+ # Socket Mode acknowledgements are handled automatically by the
189
+ # SocketMode::Client, so this returns a no-op for handlers.
190
+ #
191
+ # @param _payload [Hash] The event payload (unused)
192
+ # @return [Proc] The ack function
193
+ def build_ack_fn(_payload)
194
+ # Socket Mode acks are handled automatically by the client
195
+ # This allows handlers to call ack() without errors
196
+ ->(_response = nil) {}
197
+ end
198
+
199
+ # Executes a single handler with error handling
200
+ #
201
+ # If the handler raises an exception, logs the error and calls
202
+ # the configured error handler. Does not re-raise, allowing
203
+ # other handlers to continue processing.
204
+ #
205
+ # @param handler_class [Class] The handler class to execute
206
+ # @param context [Context] The context for handler execution
207
+ # @return [void]
208
+ def execute_handler(handler_class, context)
209
+ handler_class.new(context).call
210
+ rescue StandardError => e
211
+ BoltRb.logger.error "[BoltRb] Error in #{handler_class}: #{e.message}"
212
+ BoltRb.logger.error e.backtrace.first(5).join("\n")
213
+ config.error_handler&.call(e, context.payload)
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module BoltRb
6
+ # Configuration class for BoltRb applications
7
+ #
8
+ # Holds all settings including tokens, handler paths, logger,
9
+ # middleware stack, and error handling configuration.
10
+ #
11
+ # @example Basic configuration
12
+ # BoltRb.configure do |config|
13
+ # config.bot_token = ENV['SLACK_BOT_TOKEN']
14
+ # config.app_token = ENV['SLACK_APP_TOKEN']
15
+ # config.signing_secret = ENV['SLACK_SIGNING_SECRET']
16
+ # end
17
+ #
18
+ # @example Adding custom middleware
19
+ # BoltRb.configure do |config|
20
+ # config.use MyCustomMiddleware
21
+ # end
22
+ #
23
+ class Configuration
24
+ attr_accessor :bot_token, :app_token, :signing_secret,
25
+ :handler_paths, :logger, :error_handler
26
+
27
+ attr_reader :middleware
28
+
29
+ def initialize
30
+ @handler_paths = ['app/slack_handlers']
31
+ @logger = Logger.new($stdout)
32
+ @logger.level = Logger::INFO
33
+ @middleware = [BoltRb::Middleware::Logging]
34
+ end
35
+
36
+ # Add middleware to the stack
37
+ #
38
+ # @param middleware_class [Class] The middleware class to add
39
+ # @return [Array<Class>] The updated middleware stack
40
+ def use(middleware_class)
41
+ @middleware << middleware_class
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ module BoltRb
7
+ # Context wraps the incoming Slack event payload and provides
8
+ # convenience methods for responding to events.
9
+ #
10
+ # This is the object passed to event handlers and provides access to:
11
+ # - The raw payload data
12
+ # - The Slack Web API client
13
+ # - Helper methods like say(), ack(), and respond()
14
+ #
15
+ # @example Basic usage in an event handler
16
+ # app.event('message') do |ctx|
17
+ # ctx.say("You said: #{ctx.text}")
18
+ # end
19
+ #
20
+ # @example Using respond for slash commands
21
+ # app.command('/echo') do |ctx|
22
+ # ctx.ack
23
+ # ctx.respond("Echoing: #{ctx.text}")
24
+ # end
25
+ class Context
26
+ # @return [Hash] The raw payload from the Slack event
27
+ attr_reader :payload
28
+
29
+ # @return [Slack::Web::Client] The Slack Web API client
30
+ attr_reader :client
31
+
32
+ # Creates a new Context instance
33
+ #
34
+ # @param payload [Hash] The raw event payload from Slack
35
+ # @param client [Slack::Web::Client] The Slack Web API client
36
+ # @param ack [Proc] The acknowledgement function to call
37
+ def initialize(payload:, client:, ack:)
38
+ @payload = payload
39
+ @client = client
40
+ @ack_fn = ack
41
+ @acked = false
42
+ end
43
+
44
+ # Returns the event portion of the payload
45
+ #
46
+ # @return [Hash, nil] The event data or nil if not present
47
+ def event
48
+ payload['event']
49
+ end
50
+
51
+ # Extracts the user ID from the payload
52
+ #
53
+ # Handles various payload formats:
54
+ # - event.user (string)
55
+ # - user_id (slash commands)
56
+ # - user (string)
57
+ # - user.id (nested object)
58
+ #
59
+ # @return [String, nil] The user ID or nil if not found
60
+ def user
61
+ extract_id(
62
+ payload.dig('event', 'user') ||
63
+ payload['user_id'] ||
64
+ payload['user'] ||
65
+ payload.dig('user', 'id')
66
+ )
67
+ end
68
+
69
+ # Extracts the channel ID from the payload
70
+ #
71
+ # Handles various payload formats:
72
+ # - event.channel (string)
73
+ # - channel_id (slash commands)
74
+ # - channel (string)
75
+ # - channel.id (nested object)
76
+ #
77
+ # @return [String, nil] The channel ID or nil if not found
78
+ def channel
79
+ extract_id(
80
+ payload.dig('event', 'channel') ||
81
+ payload['channel_id'] ||
82
+ payload['channel'] ||
83
+ payload.dig('channel', 'id')
84
+ )
85
+ end
86
+
87
+ # Extracts the text content from the event
88
+ #
89
+ # @return [String, nil] The message text or nil if not present
90
+ def text
91
+ event&.dig('text')
92
+ end
93
+
94
+ # Acknowledges the event
95
+ #
96
+ # For events that require acknowledgement (slash commands, interactive
97
+ # components), this method sends the acknowledgement to Slack.
98
+ #
99
+ # @param response [String, Hash, nil] Optional response to include with the ack
100
+ # @return [void]
101
+ def ack(response = nil)
102
+ @ack_fn.call(response)
103
+ @acked = true
104
+ end
105
+
106
+ # Returns whether this context has been acknowledged
107
+ #
108
+ # @return [Boolean] true if ack() has been called
109
+ def acked?
110
+ @acked
111
+ end
112
+
113
+ # Posts a message to the channel
114
+ #
115
+ # @param message [String, Hash] The message to post. Can be a simple string
116
+ # or a hash with chat.postMessage options
117
+ # @return [Hash] The response from the Slack API
118
+ #
119
+ # @example Simple text message
120
+ # ctx.say("Hello!")
121
+ #
122
+ # @example Message with options
123
+ # ctx.say(text: "Hello!", thread_ts: "123.456")
124
+ def say(message)
125
+ options = message.is_a?(Hash) ? message : { text: message }
126
+ client.chat_postMessage(options.merge(channel: channel))
127
+ end
128
+
129
+ # Responds using the response_url
130
+ #
131
+ # This is used for slash commands and interactive components where
132
+ # Slack provides a response_url for sending follow-up messages.
133
+ #
134
+ # @param message [String, Hash] The message to send. Can be a simple string
135
+ # or a hash with response options (text, blocks, response_type, etc.)
136
+ # @return [Net::HTTPResponse, nil] The HTTP response or nil if no response_url
137
+ #
138
+ # @example Simple response
139
+ # ctx.respond("Processing complete!")
140
+ #
141
+ # @example Ephemeral response with blocks
142
+ # ctx.respond(
143
+ # text: "Here's your data",
144
+ # response_type: "ephemeral",
145
+ # blocks: [...]
146
+ # )
147
+ def respond(message)
148
+ response_url = payload['response_url']
149
+ return unless response_url
150
+
151
+ options = message.is_a?(Hash) ? message : { text: message }
152
+ uri = URI(response_url)
153
+ http = Net::HTTP.new(uri.host, uri.port)
154
+ http.use_ssl = true
155
+ request = Net::HTTP::Post.new(uri.path)
156
+ request['Content-Type'] = 'application/json'
157
+ request.body = options.to_json
158
+ http.request(request)
159
+ end
160
+
161
+ private
162
+
163
+ # Extracts an ID from a value that might be a string or hash
164
+ #
165
+ # @param value [String, Hash, nil] The value to extract from
166
+ # @return [String, nil] The extracted ID
167
+ def extract_id(value)
168
+ return nil if value.nil?
169
+
170
+ value.is_a?(Hash) ? value['id'] : value
171
+ end
172
+ end
173
+ end