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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Handlers
5
+ # Handler for Slack shortcuts (global shortcuts and message shortcuts)
6
+ #
7
+ # This handler provides the `shortcut` DSL for matching shortcut payloads.
8
+ # Global shortcuts are triggered from the lightning bolt menu in Slack,
9
+ # while message shortcuts appear in the context menu of messages.
10
+ #
11
+ # @example Global shortcut handler
12
+ # class CreateTicketHandler < BoltRb::ShortcutHandler
13
+ # shortcut 'create_ticket'
14
+ #
15
+ # def handle
16
+ # ack
17
+ # # Open a modal with views.open using trigger_id
18
+ # end
19
+ # end
20
+ #
21
+ # @example Message shortcut handler
22
+ # class QuoteMessageHandler < BoltRb::ShortcutHandler
23
+ # shortcut 'quote_message'
24
+ #
25
+ # def handle
26
+ # ack
27
+ # text = message_text
28
+ # # Do something with the quoted message
29
+ # end
30
+ # end
31
+ #
32
+ # @example Regex-based shortcut matching
33
+ # class CreateHandler < BoltRb::ShortcutHandler
34
+ # shortcut /^create_/
35
+ #
36
+ # def handle
37
+ # ack
38
+ # # Handle any shortcut starting with 'create_'
39
+ # end
40
+ # end
41
+ class ShortcutHandler < Base
42
+ # Valid shortcut payload types
43
+ # 'shortcut' is a global shortcut (from lightning bolt menu)
44
+ # 'message_action' is a message shortcut (from message context menu)
45
+ SHORTCUT_TYPES = %w[shortcut message_action].freeze
46
+
47
+ class << self
48
+ # Configures which callback_id this handler responds to
49
+ #
50
+ # @param callback_id [String, Regexp] The callback_id to match (exact string or regex)
51
+ # @return [void]
52
+ #
53
+ # @example Match exact callback_id
54
+ # shortcut 'create_ticket'
55
+ #
56
+ # @example Match callback_id pattern
57
+ # shortcut /^create_/
58
+ def shortcut(callback_id)
59
+ @matcher_config = {
60
+ type: :shortcut,
61
+ callback_id: callback_id
62
+ }
63
+ end
64
+
65
+ # Determines if this handler matches the given payload
66
+ #
67
+ # Checks if the payload is a shortcut or message_action type and if the
68
+ # callback_id matches the configured pattern.
69
+ #
70
+ # @param payload [Hash] The incoming Slack shortcut payload
71
+ # @return [Boolean] true if this handler should process the shortcut
72
+ def matches?(payload)
73
+ return false unless matcher_config
74
+ return false unless SHORTCUT_TYPES.include?(payload['type'])
75
+
76
+ if matcher_config[:callback_id].is_a?(Regexp)
77
+ matcher_config[:callback_id].match?(payload['callback_id'])
78
+ else
79
+ payload['callback_id'] == matcher_config[:callback_id]
80
+ end
81
+ end
82
+ end
83
+
84
+ # Returns the callback_id from the shortcut payload
85
+ #
86
+ # @return [String, nil] The callback_id
87
+ def callback_id
88
+ payload['callback_id']
89
+ end
90
+
91
+ # Returns the trigger_id for opening modals
92
+ #
93
+ # Slack provides a trigger_id with shortcuts that can be used
94
+ # to open modals within 3 seconds of receiving the shortcut.
95
+ #
96
+ # @return [String, nil] The trigger_id for views.open
97
+ def trigger_id
98
+ payload['trigger_id']
99
+ end
100
+
101
+ # Returns the type of shortcut (:global or :message)
102
+ #
103
+ # @return [Symbol] :message for message shortcuts (message_action),
104
+ # :global for global shortcuts
105
+ def shortcut_type
106
+ payload['type'] == 'message_action' ? :message : :global
107
+ end
108
+
109
+ # Returns the message object for message shortcuts
110
+ #
111
+ # Only available for message shortcuts (message_action type).
112
+ # Contains the original message that the shortcut was triggered on.
113
+ #
114
+ # @return [Hash, nil] The message object or nil for global shortcuts
115
+ def message
116
+ payload['message']
117
+ end
118
+
119
+ # Returns the text of the message for message shortcuts
120
+ #
121
+ # Convenience method to get the message text directly.
122
+ #
123
+ # @return [String, nil] The message text or nil if not available
124
+ def message_text
125
+ message&.dig('text')
126
+ end
127
+ end
128
+ end
129
+
130
+ # Top-level alias for convenience
131
+ ShortcutHandler = Handlers::ShortcutHandler
132
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Handlers
5
+ # Handler for Slack view submissions (modal form submissions)
6
+ #
7
+ # This handler provides the `view` DSL for matching view_submission payloads.
8
+ # View submissions are triggered when users click the submit button on modals
9
+ # opened via views.open or views.push.
10
+ #
11
+ # @example Basic modal submission handler
12
+ # class CreateTicketSubmitHandler < BoltRb::ViewSubmissionHandler
13
+ # view 'create_ticket_modal'
14
+ #
15
+ # def handle
16
+ # ack
17
+ # # Process the form submission
18
+ # ticket_title = values.dig('title_block', 'title_input', 'value')
19
+ # end
20
+ # end
21
+ #
22
+ # @example Handler with validation errors
23
+ # class ValidatedSubmitHandler < BoltRb::ViewSubmissionHandler
24
+ # view 'validated_form'
25
+ #
26
+ # def handle
27
+ # if invalid_input?
28
+ # ack(response_action: 'errors', errors: { 'input_block' => 'Invalid input' })
29
+ # else
30
+ # ack
31
+ # process_submission
32
+ # end
33
+ # end
34
+ # end
35
+ #
36
+ # @example Regex-based view matching
37
+ # class DynamicFormHandler < BoltRb::ViewSubmissionHandler
38
+ # view /^form_step_/
39
+ #
40
+ # def handle
41
+ # ack
42
+ # step = callback_id.gsub('form_step_', '')
43
+ # # Handle based on step
44
+ # end
45
+ # end
46
+ class ViewSubmissionHandler < Base
47
+ class << self
48
+ # Configures which callback_id this handler responds to
49
+ #
50
+ # @param callback_id [String, Regexp] The callback_id to match (exact string or regex)
51
+ # @return [void]
52
+ #
53
+ # @example Match exact callback_id
54
+ # view 'create_ticket_modal'
55
+ #
56
+ # @example Match callback_id pattern
57
+ # view /^create_/
58
+ def view(callback_id)
59
+ @matcher_config = {
60
+ type: :view_submission,
61
+ callback_id: callback_id
62
+ }
63
+ end
64
+
65
+ # Determines if this handler matches the given payload
66
+ #
67
+ # Checks if the payload is a view_submission type and if the
68
+ # callback_id matches the configured pattern.
69
+ #
70
+ # @param payload [Hash] The incoming Slack view_submission payload
71
+ # @return [Boolean] true if this handler should process the submission
72
+ def matches?(payload)
73
+ return false unless matcher_config
74
+ return false unless payload['type'] == 'view_submission'
75
+
76
+ view_callback_id = payload.dig('view', 'callback_id')
77
+ return false unless view_callback_id
78
+
79
+ if matcher_config[:callback_id].is_a?(Regexp)
80
+ matcher_config[:callback_id].match?(view_callback_id)
81
+ else
82
+ view_callback_id == matcher_config[:callback_id]
83
+ end
84
+ end
85
+ end
86
+
87
+ # Returns the view object from the payload
88
+ #
89
+ # @return [Hash, nil] The view object containing callback_id, private_metadata, state, etc.
90
+ def view
91
+ payload['view']
92
+ end
93
+
94
+ # Returns the callback_id from the view
95
+ #
96
+ # @return [String, nil] The callback_id
97
+ def callback_id
98
+ view&.dig('callback_id')
99
+ end
100
+
101
+ # Returns the private_metadata from the view
102
+ #
103
+ # Private metadata is a string field you can use to pass data between
104
+ # the view open and submission events. Often used to store IDs.
105
+ #
106
+ # @return [String, nil] The private_metadata value
107
+ def private_metadata
108
+ view&.dig('private_metadata')
109
+ end
110
+
111
+ # Returns the state values from the submitted form
112
+ #
113
+ # The values hash is keyed by block_id, then action_id, then contains
114
+ # the input value (format depends on input type).
115
+ #
116
+ # @return [Hash] The form values hash
117
+ # @example Structure
118
+ # {
119
+ # 'title_block' => {
120
+ # 'title_input' => { 'type' => 'plain_text_input', 'value' => 'My Title' }
121
+ # },
122
+ # 'select_block' => {
123
+ # 'select_input' => { 'type' => 'static_select', 'selected_option' => { 'value' => 'opt1' } }
124
+ # }
125
+ # }
126
+ def values
127
+ view&.dig('state', 'values') || {}
128
+ end
129
+
130
+ # Returns the user ID from the payload
131
+ #
132
+ # @return [String, nil] The user ID who submitted the form
133
+ def user_id
134
+ payload.dig('user', 'id')
135
+ end
136
+
137
+ # Returns the response URLs for block-based responses
138
+ #
139
+ # Only present if the modal was opened from a message interaction.
140
+ #
141
+ # @return [Array<Hash>] Array of response_url objects
142
+ def response_urls
143
+ payload['response_urls'] || []
144
+ end
145
+
146
+ # Returns the hash value of the submitted view
147
+ #
148
+ # Used for optimistic locking when updating views.
149
+ #
150
+ # @return [String, nil] The view hash
151
+ def view_hash
152
+ view&.dig('hash')
153
+ end
154
+ end
155
+ end
156
+
157
+ # Top-level alias for convenience
158
+ ViewSubmissionHandler = Handlers::ViewSubmissionHandler
159
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Middleware
5
+ # Base class for all middleware
6
+ #
7
+ # Middleware classes should inherit from this and override #call
8
+ # to implement custom behavior. The default implementation simply
9
+ # yields to the next middleware in the chain.
10
+ #
11
+ # @example Custom middleware
12
+ # class AuthMiddleware < BoltRb::Middleware::Base
13
+ # def call(context)
14
+ # if authorized?(context)
15
+ # yield if block_given?
16
+ # else
17
+ # context.respond("Unauthorized")
18
+ # end
19
+ # end
20
+ # end
21
+ class Base
22
+ # Processes the request through this middleware
23
+ #
24
+ # Override this method in subclasses to add custom behavior.
25
+ # Always call yield to continue the middleware chain.
26
+ #
27
+ # @param context [BoltRb::Context] The request context
28
+ # @yield Continues to the next middleware in the chain
29
+ # @return [void]
30
+ def call(context)
31
+ yield if block_given?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Middleware
5
+ # Executes a chain of middleware in order
6
+ #
7
+ # The middleware chain follows the onion model where each middleware
8
+ # can execute code before and after the next middleware in the chain.
9
+ # This is similar to Rack middleware or Rails around_action filters.
10
+ #
11
+ # @example Basic usage
12
+ # chain = Chain.new([LoggingMiddleware, AuthMiddleware])
13
+ # chain.call(context) do
14
+ # # Handler code runs after all middleware have yielded
15
+ # end
16
+ class Chain
17
+ # Creates a new middleware chain
18
+ #
19
+ # @param middleware_classes [Array<Class>] Array of middleware classes to instantiate and run
20
+ def initialize(middleware_classes)
21
+ @middleware_classes = middleware_classes
22
+ end
23
+
24
+ # Executes the middleware chain
25
+ #
26
+ # Each middleware is instantiated and called in order. The provided
27
+ # block is called after all middleware have yielded. Middleware can
28
+ # stop the chain by not yielding.
29
+ #
30
+ # @param context [BoltRb::Context] The request context
31
+ # @yield The handler to run after middleware processing
32
+ # @return [void]
33
+ def call(context, &block)
34
+ chain = @middleware_classes.map(&:new)
35
+ run_chain(chain, context, &block)
36
+ end
37
+
38
+ private
39
+
40
+ # Recursively runs through the middleware chain
41
+ #
42
+ # @param chain [Array<Base>] Remaining middleware instances
43
+ # @param context [BoltRb::Context] The request context
44
+ # @yield The handler to run when chain is empty
45
+ # @return [void]
46
+ def run_chain(chain, context, &block)
47
+ if chain.empty?
48
+ block.call
49
+ else
50
+ middleware = chain.shift
51
+ middleware.call(context) do
52
+ run_chain(chain, context, &block)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Middleware
5
+ # Logging middleware for request/response logging
6
+ #
7
+ # Logs the event type being processed and the time taken to process it.
8
+ # This is useful for debugging and monitoring your Bolt application.
9
+ #
10
+ # @example Output
11
+ # [BoltRb] Processing event:message
12
+ # [BoltRb] Completed event:message in 12.34ms
13
+ class Logging < Base
14
+ # Processes the request with logging
15
+ #
16
+ # Logs the event type before processing and the elapsed time after.
17
+ #
18
+ # @param context [BoltRb::Context] The request context
19
+ # @yield Continues to the next middleware in the chain
20
+ # @return [void]
21
+ def call(context)
22
+ event_type = determine_event_type(context.payload)
23
+ BoltRb.logger.info "[BoltRb] Processing #{event_type}"
24
+ started = Time.now
25
+
26
+ yield if block_given?
27
+
28
+ elapsed = ((Time.now - started) * 1000).round(2)
29
+ BoltRb.logger.info "[BoltRb] Completed #{event_type} in #{elapsed}ms"
30
+ end
31
+
32
+ private
33
+
34
+ # Determines the event type from the payload
35
+ #
36
+ # Handles various payload formats from Slack:
37
+ # - Events API: event.type
38
+ # - Slash commands: command
39
+ # - Block actions: type with action_ids
40
+ # - Shortcuts: type with callback_id
41
+ #
42
+ # @param payload [Hash] The raw Slack payload
43
+ # @return [String] A descriptive event type string
44
+ def determine_event_type(payload)
45
+ if payload['event']
46
+ "event:#{payload['event']['type']}"
47
+ elsif payload['command']
48
+ "command:#{payload['command']}"
49
+ elsif payload['type'] == 'block_actions'
50
+ action_ids = payload['actions']&.map { |a| a['action_id'] }&.join(',')
51
+ "action:#{action_ids}"
52
+ elsif payload['type'] == 'shortcut' || payload['type'] == 'message_action'
53
+ "shortcut:#{payload['callback_id']}"
54
+ else
55
+ 'unknown'
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ # Router maintains a registry of handlers and routes incoming payloads
5
+ # to the appropriate handlers based on their matching criteria.
6
+ #
7
+ # The router acts as the central dispatch mechanism for Bolt applications,
8
+ # collecting registered handlers and determining which ones should process
9
+ # a given Slack payload.
10
+ #
11
+ # @example Basic usage
12
+ # router = BoltRb::Router.new
13
+ #
14
+ # router.register(MyMessageHandler)
15
+ # router.register(MyCommandHandler)
16
+ #
17
+ # handlers = router.route(payload)
18
+ # handlers.each { |h| h.new(context).call }
19
+ #
20
+ # @example Using the global router
21
+ # BoltRb.router.register(MyHandler)
22
+ # BoltRb.router.route(payload)
23
+ class Router
24
+ # Creates a new Router instance with an empty handler registry
25
+ def initialize
26
+ @handlers = []
27
+ end
28
+
29
+ # Registers a handler class with the router
30
+ #
31
+ # The handler class should respond to .matches?(payload) to determine
32
+ # if it should process a given payload. Duplicate registrations are ignored.
33
+ #
34
+ # @param handler_class [Class] A handler class (EventHandler, CommandHandler, etc.)
35
+ # @return [void]
36
+ #
37
+ # @example
38
+ # router.register(MyMessageHandler)
39
+ def register(handler_class)
40
+ @handlers << handler_class unless @handlers.include?(handler_class)
41
+ end
42
+
43
+ # Routes a payload to all matching handlers
44
+ #
45
+ # Iterates through all registered handlers and returns those whose
46
+ # .matches?(payload) method returns true.
47
+ #
48
+ # @param payload [Hash] The incoming Slack payload
49
+ # @return [Array<Class>] Array of handler classes that match the payload
50
+ #
51
+ # @example
52
+ # payload = { 'event' => { 'type' => 'message', 'text' => 'hello' } }
53
+ # handlers = router.route(payload)
54
+ # # => [MessageHandler, HelloHandler]
55
+ def route(payload)
56
+ @handlers.select { |handler| handler.matches?(payload) }
57
+ end
58
+
59
+ # Returns the number of registered handlers
60
+ #
61
+ # @return [Integer] The count of registered handlers
62
+ def handler_count
63
+ @handlers.length
64
+ end
65
+
66
+ # Removes all registered handlers
67
+ #
68
+ # Useful for testing or reconfiguration scenarios.
69
+ #
70
+ # @return [void]
71
+ def clear
72
+ @handlers.clear
73
+ end
74
+ end
75
+ end