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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Handlers
5
+ # Handler for Slack interactive component actions (buttons, select menus, etc.)
6
+ #
7
+ # This handler provides the `action` DSL for matching block_actions payloads.
8
+ # Block actions are triggered when users interact with interactive components
9
+ # like buttons, overflow menus, date pickers, and select menus in messages
10
+ # or modals.
11
+ #
12
+ # @example Basic button handler
13
+ # class ApproveHandler < BoltRb::ActionHandler
14
+ # action 'approve_button'
15
+ #
16
+ # def handle
17
+ # ack
18
+ # say("Approved by <@#{user}>!")
19
+ # end
20
+ # end
21
+ #
22
+ # @example Handler with block_id filter
23
+ # class RequestApprovalHandler < BoltRb::ActionHandler
24
+ # action 'approve', block_id: 'approval_block'
25
+ #
26
+ # def handle
27
+ # ack
28
+ # # Handle approval request
29
+ # end
30
+ # end
31
+ #
32
+ # @example Regex-based action matching
33
+ # class DynamicButtonHandler < BoltRb::ActionHandler
34
+ # action /^approve_request_/
35
+ #
36
+ # def handle
37
+ # ack
38
+ # request_id = action_id.gsub('approve_request_', '')
39
+ # # Process the request
40
+ # end
41
+ # end
42
+ class ActionHandler < Base
43
+ class << self
44
+ # Configures which action_id this handler responds to
45
+ #
46
+ # @param action_id [String, Regexp] The action_id to match (exact string or regex)
47
+ # @param block_id [String, Regexp, nil] Optional block_id filter for more specific matching
48
+ # @return [void]
49
+ #
50
+ # @example Match exact action_id
51
+ # action 'approve_button'
52
+ #
53
+ # @example Match with block_id
54
+ # action 'approve_button', block_id: 'approval_block'
55
+ #
56
+ # @example Match action_id pattern
57
+ # action /^approve_/
58
+ def action(action_id, block_id: nil)
59
+ @matcher_config = {
60
+ type: :action,
61
+ action_id: action_id,
62
+ block_id: block_id
63
+ }
64
+ end
65
+
66
+ # Determines if this handler matches the given payload
67
+ #
68
+ # Checks if the payload is a block_actions type and if any of the
69
+ # actions in the payload match the configured action_id and optional block_id.
70
+ #
71
+ # @param payload [Hash] The incoming Slack block_actions payload
72
+ # @return [Boolean] true if this handler should process the action
73
+ def matches?(payload)
74
+ return false unless matcher_config
75
+ return false unless payload['type'] == 'block_actions'
76
+
77
+ actions = payload['actions'] || []
78
+ actions.any? do |action|
79
+ action_matches?(action) && block_matches?(action)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Checks if the action's action_id matches the configured pattern
86
+ #
87
+ # @param action [Hash] A single action from the payload
88
+ # @return [Boolean] true if action_id matches
89
+ def action_matches?(action)
90
+ if matcher_config[:action_id].is_a?(Regexp)
91
+ matcher_config[:action_id].match?(action['action_id'])
92
+ else
93
+ action['action_id'] == matcher_config[:action_id]
94
+ end
95
+ end
96
+
97
+ # Checks if the action's block_id matches the configured pattern
98
+ #
99
+ # If no block_id is configured, this always returns true (no filtering).
100
+ #
101
+ # @param action [Hash] A single action from the payload
102
+ # @return [Boolean] true if block_id matches or no block_id filter is set
103
+ def block_matches?(action)
104
+ return true if matcher_config[:block_id].nil?
105
+
106
+ if matcher_config[:block_id].is_a?(Regexp)
107
+ matcher_config[:block_id].match?(action['block_id'])
108
+ else
109
+ action['block_id'] == matcher_config[:block_id]
110
+ end
111
+ end
112
+ end
113
+
114
+ # Returns the first action from the payload
115
+ #
116
+ # Block actions payloads can contain multiple actions, but typically
117
+ # only one action is triggered at a time. This returns the first action.
118
+ #
119
+ # @return [Hash, nil] The action hash containing action_id, value, etc.
120
+ def action
121
+ payload['actions']&.first
122
+ end
123
+
124
+ # Returns the action_id from the triggered action
125
+ #
126
+ # @return [String, nil] The action_id
127
+ def action_id
128
+ action&.dig('action_id')
129
+ end
130
+
131
+ # Returns the value from the triggered action
132
+ #
133
+ # For buttons this is the button's value. For select menus this is
134
+ # the selected option's value.
135
+ #
136
+ # @return [String, nil] The action value
137
+ def action_value
138
+ action&.dig('value')
139
+ end
140
+
141
+ # Returns the block_id from the triggered action
142
+ #
143
+ # @return [String, nil] The block_id
144
+ def block_id
145
+ action&.dig('block_id')
146
+ end
147
+
148
+ # Returns the trigger_id for opening modals
149
+ #
150
+ # Slack provides a trigger_id with interactive actions that can be used
151
+ # to open modals within 3 seconds of receiving the action.
152
+ #
153
+ # @return [String, nil] The trigger_id for views.open
154
+ def trigger_id
155
+ payload['trigger_id']
156
+ end
157
+ end
158
+ end
159
+
160
+ # Top-level alias for convenience
161
+ ActionHandler = Handlers::ActionHandler
162
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Handlers
5
+ # Base class for all Slack event handlers.
6
+ #
7
+ # This provides the common interface and middleware execution that all
8
+ # handler types (Event, Command, Action, Shortcut) inherit from.
9
+ #
10
+ # Subclasses should:
11
+ # - Override .matches?(payload) to define matching logic
12
+ # - Override #handle to implement the handler behavior
13
+ # - Optionally use .use(middleware) to add handler-specific middleware
14
+ #
15
+ # @example Creating a custom handler
16
+ # class MyHandler < BoltRb::Handlers::Base
17
+ # use MyCustomMiddleware
18
+ #
19
+ # def self.matches?(payload)
20
+ # payload.dig('event', 'type') == 'message'
21
+ # end
22
+ #
23
+ # def handle
24
+ # say("Received your message!")
25
+ # end
26
+ # end
27
+ class Base
28
+ class << self
29
+ # Configuration for matching this handler to payloads
30
+ # Subclasses should set this to define their matching criteria
31
+ #
32
+ # @return [Object, nil] The matcher configuration
33
+ attr_reader :matcher_config
34
+
35
+ # Returns the middleware stack for this handler class
36
+ #
37
+ # @return [Array<Class>] Array of middleware classes
38
+ def middleware_stack
39
+ @middleware_stack ||= []
40
+ end
41
+
42
+ # Adds middleware to this handler's stack
43
+ #
44
+ # Middleware is executed in the order added, wrapping the #handle method.
45
+ # Each middleware should call yield to continue the chain.
46
+ #
47
+ # @param middleware_class [Class] The middleware class to add
48
+ # @return [void]
49
+ #
50
+ # @example Adding middleware
51
+ # class MyHandler < Base
52
+ # use LoggingMiddleware
53
+ # use AuthenticationMiddleware
54
+ # end
55
+ def use(middleware_class)
56
+ middleware_stack << middleware_class
57
+ end
58
+
59
+ # Determines if this handler matches the given payload
60
+ #
61
+ # Base implementation always returns false. Subclasses should override
62
+ # this to implement their matching logic.
63
+ #
64
+ # @param _payload [Hash] The incoming Slack payload
65
+ # @return [Boolean] true if this handler should process the payload
66
+ def matches?(_payload)
67
+ false
68
+ end
69
+
70
+ # Hook called when a class inherits from Base
71
+ #
72
+ # Ensures each subclass gets its own independent middleware stack
73
+ # and registers the handler with the global router.
74
+ #
75
+ # @param subclass [Class] The inheriting class
76
+ # @return [void]
77
+ def inherited(subclass)
78
+ super
79
+ subclass.instance_variable_set(:@middleware_stack, [])
80
+ # Auto-register with the global router
81
+ BoltRb.router.register(subclass)
82
+ end
83
+ end
84
+
85
+ # @return [Context] The context object for this handler invocation
86
+ attr_reader :context
87
+
88
+ # Creates a new handler instance
89
+ #
90
+ # @param context [Context] The context containing payload, client, and ack
91
+ def initialize(context)
92
+ @context = context
93
+ end
94
+
95
+ # Returns the raw payload from the context
96
+ #
97
+ # @return [Hash] The Slack event payload
98
+ def payload
99
+ context.payload
100
+ end
101
+
102
+ # Returns the Slack Web API client
103
+ #
104
+ # @return [Slack::Web::Client] The API client
105
+ def client
106
+ context.client
107
+ end
108
+
109
+ # Returns the user ID from the context
110
+ #
111
+ # @return [String, nil] The user ID
112
+ def user
113
+ context.user
114
+ end
115
+
116
+ # Returns the channel ID from the context
117
+ #
118
+ # @return [String, nil] The channel ID
119
+ def channel
120
+ context.channel
121
+ end
122
+
123
+ # Posts a message to the channel
124
+ #
125
+ # Delegates to Context#say
126
+ #
127
+ # @param message [String, Hash] The message to post
128
+ # @return [Hash] The Slack API response
129
+ def say(message)
130
+ context.say(message)
131
+ end
132
+
133
+ # Acknowledges the event
134
+ #
135
+ # Delegates to Context#ack
136
+ #
137
+ # @param response [String, Hash, nil] Optional response to include
138
+ # @return [void]
139
+ def ack(response = nil)
140
+ context.ack(response)
141
+ end
142
+
143
+ # Responds using the response_url
144
+ #
145
+ # Delegates to Context#respond
146
+ #
147
+ # @param message [String, Hash] The message to send
148
+ # @return [Net::HTTPResponse, nil] The HTTP response
149
+ def respond(message)
150
+ context.respond(message)
151
+ end
152
+
153
+ # Executes this handler
154
+ #
155
+ # Runs the middleware stack, then calls #handle at the end of the chain.
156
+ #
157
+ # @return [void]
158
+ def call
159
+ run_middleware { handle }
160
+ end
161
+
162
+ # The main handler logic
163
+ #
164
+ # Subclasses must override this method to implement their behavior.
165
+ #
166
+ # @raise [NotImplementedError] Always raises in the base class
167
+ def handle
168
+ raise NotImplementedError, 'Subclasses must implement #handle'
169
+ end
170
+
171
+ private
172
+
173
+ # Executes the middleware chain, then the given block
174
+ #
175
+ # Creates a recursive chain where each middleware can call yield
176
+ # to invoke the next middleware (or the final block).
177
+ #
178
+ # @yield The block to execute at the end of the chain
179
+ # @return [void]
180
+ def run_middleware(&block)
181
+ chain = self.class.middleware_stack.dup
182
+ run_next = proc do
183
+ if chain.empty?
184
+ block.call
185
+ else
186
+ middleware = chain.shift.new
187
+ middleware.call(context) { run_next.call }
188
+ end
189
+ end
190
+ run_next.call
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Handlers
5
+ # Handler for Slack slash commands (/deploy, /help, etc.)
6
+ #
7
+ # This handler provides the `command` DSL for matching slash command invocations.
8
+ # Slash commands have a different payload structure than events, with fields like
9
+ # `user_id`, `channel_id`, and `trigger_id` at the top level.
10
+ #
11
+ # @example Basic command handler
12
+ # class DeployHandler < BoltRb::CommandHandler
13
+ # command '/deploy'
14
+ #
15
+ # def handle
16
+ # ack
17
+ # say("Deploying: #{command_text}")
18
+ # end
19
+ # end
20
+ #
21
+ # @example Command with modal
22
+ # class SettingsHandler < BoltRb::CommandHandler
23
+ # command '/settings'
24
+ #
25
+ # def handle
26
+ # ack
27
+ # client.views_open(
28
+ # trigger_id: trigger_id,
29
+ # view: { type: 'modal', ... }
30
+ # )
31
+ # end
32
+ # end
33
+ class CommandHandler < Base
34
+ class << self
35
+ # Configures which slash command this handler responds to
36
+ #
37
+ # @param command_name [String] The slash command to handle (e.g., '/deploy')
38
+ # @return [void]
39
+ #
40
+ # @example
41
+ # command '/deploy'
42
+ def command(command_name)
43
+ @matcher_config = {
44
+ type: :command,
45
+ command: command_name
46
+ }
47
+ end
48
+
49
+ # Determines if this handler matches the given payload
50
+ #
51
+ # Checks if the payload's command field matches the configured command.
52
+ #
53
+ # @param payload [Hash] The incoming Slack command payload
54
+ # @return [Boolean] true if this handler should process the command
55
+ def matches?(payload)
56
+ return false unless matcher_config
57
+
58
+ payload['command'] == matcher_config[:command]
59
+ end
60
+ end
61
+
62
+ # Returns the slash command that was invoked
63
+ #
64
+ # @return [String] The command name (e.g., '/deploy')
65
+ def command_name
66
+ payload['command']
67
+ end
68
+
69
+ # Returns the text provided after the command
70
+ #
71
+ # For `/deploy production --force`, this returns "production --force"
72
+ #
73
+ # @return [String, nil] The text argument or nil if none provided
74
+ def command_text
75
+ payload['text']
76
+ end
77
+
78
+ # Returns the command parameters as a hash
79
+ #
80
+ # @return [Hash] Hash containing :text key with command text
81
+ def params
82
+ { text: command_text }
83
+ end
84
+
85
+ # Returns the trigger_id for opening modals
86
+ #
87
+ # Slack provides a trigger_id with slash commands that can be used
88
+ # to open modals within 3 seconds of receiving the command.
89
+ #
90
+ # @return [String, nil] The trigger_id for views.open
91
+ def trigger_id
92
+ payload['trigger_id']
93
+ end
94
+
95
+ # Returns the user ID who invoked the command
96
+ #
97
+ # Overrides Base#user because slash command payloads use 'user_id'
98
+ # instead of nested event.user structure.
99
+ #
100
+ # @return [String, nil] The user ID
101
+ def user
102
+ payload['user_id'] || super
103
+ end
104
+
105
+ # Returns the channel ID where the command was invoked
106
+ #
107
+ # Overrides Base#channel because slash command payloads use 'channel_id'
108
+ # instead of nested event.channel structure.
109
+ #
110
+ # @return [String, nil] The channel ID
111
+ def channel
112
+ payload['channel_id'] || super
113
+ end
114
+ end
115
+ end
116
+
117
+ # Top-level alias for convenience
118
+ CommandHandler = Handlers::CommandHandler
119
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Handlers
5
+ # Handler for Slack events (message, app_mention, reaction_added, etc.)
6
+ #
7
+ # This handler provides the `listen_to` DSL for matching event types
8
+ # and optionally filtering by text patterns.
9
+ #
10
+ # @example Basic message handler
11
+ # class GreetingHandler < BoltRb::EventHandler
12
+ # listen_to :message
13
+ #
14
+ # def handle
15
+ # say("Hello, #{user}!")
16
+ # end
17
+ # end
18
+ #
19
+ # @example Handler with pattern matching
20
+ # class HelloHandler < BoltRb::EventHandler
21
+ # listen_to :message, pattern: /hello/i
22
+ #
23
+ # def handle
24
+ # say("Hello to you too!")
25
+ # end
26
+ # end
27
+ #
28
+ # @example App mention handler
29
+ # class MentionHandler < BoltRb::EventHandler
30
+ # listen_to :app_mention
31
+ #
32
+ # def handle
33
+ # say("You mentioned me in #{channel}!")
34
+ # end
35
+ # end
36
+ class EventHandler < Base
37
+ class << self
38
+ # Configures which event type this handler responds to
39
+ #
40
+ # @param event_type [Symbol, String] The Slack event type to listen for
41
+ # (e.g., :message, :app_mention, :reaction_added)
42
+ # @param pattern [Regexp, nil] Optional regex pattern to match against
43
+ # the event's text field
44
+ # @return [void]
45
+ #
46
+ # @example Listen to all messages
47
+ # listen_to :message
48
+ #
49
+ # @example Listen to messages matching a pattern
50
+ # listen_to :message, pattern: /help/i
51
+ def listen_to(event_type, pattern: nil)
52
+ @matcher_config = {
53
+ type: :event,
54
+ event_type: event_type,
55
+ pattern: pattern
56
+ }
57
+ end
58
+
59
+ # Determines if this handler matches the given payload
60
+ #
61
+ # Checks the event type and optionally the text pattern.
62
+ #
63
+ # @param payload [Hash] The incoming Slack event payload
64
+ # @return [Boolean] true if this handler should process the event
65
+ def matches?(payload)
66
+ return false unless matcher_config
67
+
68
+ event = payload['event']
69
+ return false unless event
70
+ return false unless event['type'].to_s == matcher_config[:event_type].to_s
71
+
72
+ if matcher_config[:pattern]
73
+ return false if event['text'].nil?
74
+ return matcher_config[:pattern].match?(event['text'])
75
+ end
76
+
77
+ true
78
+ end
79
+ end
80
+
81
+ # Returns the event portion of the payload
82
+ #
83
+ # @return [Hash, nil] The event data
84
+ def event
85
+ payload['event']
86
+ end
87
+
88
+ # Returns the text content of the event
89
+ #
90
+ # @return [String, nil] The message text
91
+ def text
92
+ event&.dig('text')
93
+ end
94
+
95
+ # Returns the thread timestamp if this message is in a thread
96
+ #
97
+ # @return [String, nil] The thread_ts value
98
+ def thread_ts
99
+ event&.dig('thread_ts')
100
+ end
101
+
102
+ # Returns the message timestamp
103
+ #
104
+ # @return [String, nil] The ts value
105
+ def ts
106
+ event&.dig('ts')
107
+ end
108
+ end
109
+ end
110
+
111
+ # Top-level alias for convenience
112
+ EventHandler = Handlers::EventHandler
113
+ end