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
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
|
data/lib/bolt_rb/app.rb
ADDED
|
@@ -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
|