bolt_rb 0.3.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52fe93311b8c4ca74313bcd5576954cdca034b6aa08c567e4a787a0c9811a3f8
4
- data.tar.gz: 9baa07f42f2ff72fc7f5a876b4ec1bef04e34074defe5fcd944ca075b7d7f44d
3
+ metadata.gz: c4a88c6dfe625ec417db209cf02d879226ca184d5e4dbbc7c69525e8a2ed1b11
4
+ data.tar.gz: 55e054a4575b2f4831d00b9e15154e3e140a6dc13f433e756cc06d5308dd0780
5
5
  SHA512:
6
- metadata.gz: a9157156be2d39fa0a3d98adef903c13bdb9b030fee4589f776a6b8254ca3d4fa65701edaa5f9a58bb561134148f964e39d0fa2e7fe790052f25035b6546949f
7
- data.tar.gz: edd9ee321276795d73a96f40a86e2807bd48bb5e3fc012445b19b5c9885e836d501fc4a8969c4b68464e921cf5ac0f2236d9ad0ed6d579e98572070f4d1eb1bc
6
+ metadata.gz: 70e1cd7cbb16cebd74676223ac38e7b5035b61df1d845126c2a23220b453d3e5b1ae443fd6d903f795f637d2887d46457350cc0986176b0cf95a2a5171f16aa4
7
+ data.tar.gz: 3ebc0503aedba7d0d2ee617b40cabc491b2b22f0fc92d199ecdf99de18e82b2b08d924b1716e1cb7a116c02eb8d3fc9217e4c872111aefdd53b27e58c6731c4f
data/README.md CHANGED
@@ -46,6 +46,14 @@ app.start
46
46
  | `bot_token` | Your Slack bot token (`xoxb-...`) |
47
47
  | `app_token` | Your Slack app-level token (`xapp-...`) for Socket Mode |
48
48
  | `handler_paths` | Array of directories to load handlers from |
49
+ | `worker_threads` | Number of threads that run handlers. Default `5`. |
50
+ | `assistant_thread_context_store` | Store for AI assistant thread context. Default is in-process memory. |
51
+
52
+ ### Concurrency
53
+
54
+ Handlers run on a pool of worker threads, not on the Socket Mode reader thread. A handler that waits on a slow API call does not block pings or later events. Raise `worker_threads` if many events wait in the queue. Lower it to `1` if your handlers share state that is not thread safe.
55
+
56
+ On shutdown the app stops reading new events, finishes the handlers already in progress, and then exits.
49
57
 
50
58
  ## Handlers
51
59
 
@@ -189,6 +197,62 @@ end
189
197
 
190
198
  **Available methods:** `view`, `callback_id`, `private_metadata`, `is_cleared?`, `user_id`, `ack`, `client`
191
199
 
200
+ ### AI Assistants
201
+
202
+ Build an [AI app](https://docs.slack.dev/ai/agents) that lives in the Slack assistant panel. One handler class receives the full thread lifecycle:
203
+
204
+ ```ruby
205
+ class SupportAssistant < BoltRb::AssistantHandler
206
+ # Slack opened a new assistant thread
207
+ def thread_started
208
+ say "Hi <@#{user}>! How can I help?"
209
+ set_suggested_prompts(
210
+ ['Summarize this channel', { title: 'Open tickets', message: 'List my open tickets' }],
211
+ title: 'Try one of these'
212
+ )
213
+ end
214
+
215
+ # The user moved to a different channel while the thread stayed open
216
+ def context_changed
217
+ # Optional. The new context is already saved for you.
218
+ end
219
+
220
+ # The user sent a message in the thread
221
+ def user_message
222
+ set_status 'is thinking...'
223
+ set_title text[0, 50]
224
+
225
+ channel_in_view = thread_context&.dig('channel_id')
226
+ say "You asked about <##{channel_in_view}>: #{text}"
227
+ end
228
+ end
229
+ ```
230
+
231
+ `thread_started` and `user_message` are required. `context_changed` is optional.
232
+
233
+ `say` posts into the assistant thread. `set_status`, `set_title`, and `set_suggested_prompts` call the `assistant.threads.*` API methods for the current thread.
234
+
235
+ **Thread context.** Slack sends the user's active channel with `assistant_thread_started` and `assistant_thread_context_changed`. The handler saves that context, so `thread_context` returns it during later user messages. The default store lives in process memory. Set your own store to share it across processes:
236
+
237
+ ```ruby
238
+ BoltRb.configure do |config|
239
+ # Any object that responds to
240
+ # get(channel_id:, thread_ts:) -> Hash or nil
241
+ # save(channel_id:, thread_ts:, context:) -> void
242
+ config.assistant_thread_context_store = RedisThreadContextStore.new
243
+ end
244
+ ```
245
+
246
+ **Testing.** The payload factory builds all three event types:
247
+
248
+ ```ruby
249
+ payload.assistant_thread_started(context: { 'channel_id' => 'C123' })
250
+ payload.assistant_thread_context_changed(context: { 'channel_id' => 'C456' })
251
+ payload.assistant_message(text: 'hello', thread_ts: '1700000000.000100')
252
+ ```
253
+
254
+ **Available methods:** `event`, `assistant_thread`, `thread_ts`, `text`, `user`, `channel`, `thread_context`, `save_thread_context`, `say`, `set_status`, `set_title`, `set_suggested_prompts`, `client`
255
+
192
256
  ## Handler Methods
193
257
 
194
258
  All handlers have access to:
@@ -226,6 +290,12 @@ end
226
290
  4. Add a **Bot Token** with the scopes you need (e.g., `chat:write`, `commands`)
227
291
  5. Install the app to your workspace
228
292
 
293
+ For AI assistants, also:
294
+
295
+ 1. Enable **Agents & AI Apps** under Features
296
+ 2. Add the `assistant:write`, `chat:write`, and `im:history` bot scopes
297
+ 3. Subscribe to the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events
298
+
229
299
  ## Development
230
300
 
231
301
  ```bash
data/lib/bolt_rb/app.rb CHANGED
@@ -39,28 +39,37 @@ module BoltRb
39
39
  # @return [SocketMode::Client] The Socket Mode client instance
40
40
  attr_reader :socket_client
41
41
 
42
+ # @return [WorkerPool] The pool that runs handlers off the socket thread
43
+ attr_reader :worker_pool
44
+
42
45
  # Creates a new App instance
43
46
  #
44
- # Initializes the Slack Web API client for making API calls
45
- # and the Socket Mode client for receiving events.
47
+ # Initializes the Slack Web API client for making API calls,
48
+ # the worker pool for running handlers, and the Socket Mode
49
+ # client for receiving events.
46
50
  def initialize
47
51
  @config = BoltRb.configuration
48
52
  @router = BoltRb.router
49
53
  @client = Slack::Web::Client.new(token: config.bot_token)
54
+ @worker_pool = WorkerPool.new(size: config.worker_threads, logger: BoltRb.logger)
50
55
 
51
56
  setup_socket_client
52
57
  end
53
58
 
54
- # Starts the Socket Mode connection
59
+ # Starts the worker pool and the Socket Mode connection
55
60
  #
56
61
  # Loads all handlers from configured paths and connects to Slack
57
- # via Socket Mode to start receiving events.
62
+ # via Socket Mode to start receiving events. Blocks until the
63
+ # socket client stops, then drains the worker pool.
58
64
  #
59
65
  # @return [void]
60
66
  def start
61
67
  load_handlers
62
- BoltRb.logger.info '[BoltRb] Starting app...'
68
+ BoltRb.logger.info "[BoltRb] Starting app with #{config.worker_threads} worker threads..."
69
+ @worker_pool.start
63
70
  @socket_client.start
71
+ ensure
72
+ @worker_pool.shutdown
64
73
  end
65
74
 
66
75
  # Stops the Socket Mode connection
@@ -128,14 +137,17 @@ module BoltRb
128
137
 
129
138
  # Handles incoming Socket Mode events
130
139
  #
131
- # Extracts the payload from the Socket Mode envelope and routes it
132
- # to the appropriate handlers.
140
+ # Extracts the payload from the Socket Mode envelope on the socket
141
+ # thread, then hands it to the worker pool so the socket thread can
142
+ # return to reading frames.
133
143
  #
134
144
  # @param data [Hash] The Socket Mode envelope data
135
145
  # @return [void]
136
146
  def handle_socket_event(data)
137
147
  payload = extract_payload(data)
138
- process_event(payload) if payload
148
+ return unless payload
149
+
150
+ @worker_pool.post { process_event(payload) }
139
151
  rescue StandardError => e
140
152
  BoltRb.logger.error "[BoltRb] Error handling socket event: #{e.message}"
141
153
  BoltRb.logger.error e.backtrace.first(5).join("\n")
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Assistant
5
+ # In-memory store for assistant thread context.
6
+ #
7
+ # Slack sends the user's active channel with `assistant_thread_started`
8
+ # and `assistant_thread_context_changed`. This store keeps that context
9
+ # so later user messages in the same thread can read it.
10
+ #
11
+ # Data lives only in the current process. Give
12
+ # `BoltRb.configuration.assistant_thread_context_store` an object with
13
+ # the same `get` and `save` methods to persist across processes.
14
+ #
15
+ # @example Custom store
16
+ # class RedisThreadContextStore
17
+ # def get(channel_id:, thread_ts:)
18
+ # json = redis.get("assistant:#{channel_id}:#{thread_ts}")
19
+ # json && JSON.parse(json)
20
+ # end
21
+ #
22
+ # def save(channel_id:, thread_ts:, context:)
23
+ # redis.set("assistant:#{channel_id}:#{thread_ts}", context.to_json)
24
+ # end
25
+ # end
26
+ class MemoryThreadContextStore
27
+ def initialize
28
+ @contexts = {}
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ # Read the saved context for a thread
33
+ #
34
+ # @param channel_id [String] The DM channel ID
35
+ # @param thread_ts [String] The thread timestamp
36
+ # @return [Hash, nil] The saved context or nil
37
+ def get(channel_id:, thread_ts:)
38
+ @mutex.synchronize { @contexts[key(channel_id, thread_ts)] }
39
+ end
40
+
41
+ # Save the context for a thread
42
+ #
43
+ # @param channel_id [String] The DM channel ID
44
+ # @param thread_ts [String] The thread timestamp
45
+ # @param context [Hash] The context to save
46
+ # @return [void]
47
+ def save(channel_id:, thread_ts:, context:)
48
+ @mutex.synchronize { @contexts[key(channel_id, thread_ts)] = context }
49
+ end
50
+
51
+ private
52
+
53
+ # Build the lookup key for a thread
54
+ #
55
+ # @return [String]
56
+ def key(channel_id, thread_ts)
57
+ "#{channel_id}:#{thread_ts}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -24,6 +24,11 @@ module BoltRb
24
24
  attr_accessor :bot_token, :app_token, :signing_secret,
25
25
  :handler_paths, :logger, :error_handler
26
26
 
27
+ # @return [Integer] Number of threads that run handlers. Default 5.
28
+ attr_accessor :worker_threads
29
+
30
+ attr_writer :assistant_thread_context_store
31
+
27
32
  attr_reader :middleware
28
33
 
29
34
  def initialize
@@ -31,6 +36,18 @@ module BoltRb
31
36
  @logger = Logger.new($stdout)
32
37
  @logger.level = Logger::INFO
33
38
  @middleware = [BoltRb::Middleware::Logging]
39
+ @worker_threads = 5
40
+ end
41
+
42
+ # Return the store for assistant thread context
43
+ #
44
+ # Defaults to an in-process memory store. Any object that responds to
45
+ # `get(channel_id:, thread_ts:)` and `save(channel_id:, thread_ts:, context:)`
46
+ # can replace it.
47
+ #
48
+ # @return [Object] The thread context store
49
+ def assistant_thread_context_store
50
+ @assistant_thread_context_store ||= BoltRb::Assistant::MemoryThreadContextStore.new
34
51
  end
35
52
 
36
53
  # Add middleware to the stack
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ module Handlers
5
+ # Handler for Slack AI assistant threads.
6
+ #
7
+ # One subclass receives the three events that make up an assistant
8
+ # conversation: `assistant_thread_started`,
9
+ # `assistant_thread_context_changed`, and threaded direct messages
10
+ # from the user.
11
+ #
12
+ # @example
13
+ # class MyAssistant < BoltRb::AssistantHandler
14
+ # def thread_started
15
+ # set_suggested_prompts(['Summarize this channel'])
16
+ # end
17
+ #
18
+ # def user_message
19
+ # set_status('is thinking...')
20
+ # say("You said: #{text}")
21
+ # end
22
+ # end
23
+ class AssistantHandler < Base
24
+ THREAD_STARTED = 'assistant_thread_started'
25
+ CONTEXT_CHANGED = 'assistant_thread_context_changed'
26
+ MESSAGE = 'message'
27
+
28
+ class << self
29
+ # Match the assistant events and threaded user DMs
30
+ #
31
+ # @param payload [Hash] The incoming Slack event payload
32
+ # @return [Boolean]
33
+ def matches?(payload)
34
+ # The abstract class is auto-registered but must never run
35
+ return false if self == AssistantHandler
36
+
37
+ event = payload['event']
38
+ return false unless event
39
+
40
+ case event['type']
41
+ when THREAD_STARTED, CONTEXT_CHANGED then true
42
+ when MESSAGE then user_thread_message?(event)
43
+ else false
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Check for a human message inside a DM thread
50
+ #
51
+ # @param event [Hash] The message event
52
+ # @return [Boolean]
53
+ def user_thread_message?(event)
54
+ return false unless event['channel_type'] == 'im'
55
+ return false if event['thread_ts'].nil?
56
+ return false if event['bot_id']
57
+ return false if event['subtype']
58
+
59
+ true
60
+ end
61
+ end
62
+
63
+ # Route the event to the matching hook method
64
+ #
65
+ # @return [void]
66
+ def handle
67
+ case event['type']
68
+ when THREAD_STARTED
69
+ save_thread_context(assistant_thread['context'])
70
+ thread_started
71
+ when CONTEXT_CHANGED
72
+ save_thread_context(assistant_thread['context'])
73
+ context_changed
74
+ when MESSAGE
75
+ user_message
76
+ end
77
+ end
78
+
79
+ # Hook for a new assistant thread
80
+ #
81
+ # @raise [NotImplementedError] Subclasses must define this method
82
+ def thread_started
83
+ raise NotImplementedError, "#{self.class} must implement #thread_started"
84
+ end
85
+
86
+ # Hook for a change of the user's active channel
87
+ #
88
+ # The new context is saved before this runs. The default does nothing.
89
+ #
90
+ # @return [void]
91
+ def context_changed; end
92
+
93
+ # Hook for a user message inside the assistant thread
94
+ #
95
+ # @raise [NotImplementedError] Subclasses must define this method
96
+ def user_message
97
+ raise NotImplementedError, "#{self.class} must implement #user_message"
98
+ end
99
+
100
+ # Return the event portion of the payload
101
+ #
102
+ # @return [Hash] The event data
103
+ def event
104
+ payload['event']
105
+ end
106
+
107
+ # Return the assistant_thread object for thread events
108
+ #
109
+ # @return [Hash, nil]
110
+ def assistant_thread
111
+ event['assistant_thread']
112
+ end
113
+
114
+ # Return the DM channel ID of the assistant thread
115
+ #
116
+ # @return [String, nil]
117
+ def channel
118
+ assistant_thread&.dig('channel_id') || event['channel']
119
+ end
120
+
121
+ # Return the thread timestamp of the assistant thread
122
+ #
123
+ # @return [String, nil]
124
+ def thread_ts
125
+ assistant_thread&.dig('thread_ts') || event['thread_ts']
126
+ end
127
+
128
+ # Return the user ID that owns the assistant thread
129
+ #
130
+ # @return [String, nil]
131
+ def user
132
+ assistant_thread&.dig('user_id') || event['user']
133
+ end
134
+
135
+ # Return the message text for user messages
136
+ #
137
+ # @return [String, nil]
138
+ def text
139
+ event['text']
140
+ end
141
+
142
+ # Return the context of the assistant thread
143
+ #
144
+ # Thread events carry the context in the payload. User messages read
145
+ # it from the configured thread context store.
146
+ #
147
+ # @return [Hash, nil] Keys such as channel_id, team_id, enterprise_id
148
+ def thread_context
149
+ return assistant_thread['context'] if assistant_thread
150
+
151
+ thread_context_store.get(channel_id: channel, thread_ts: thread_ts)
152
+ end
153
+
154
+ # Save a context for the current thread
155
+ #
156
+ # @param context [Hash] The context to save
157
+ # @return [void]
158
+ def save_thread_context(context)
159
+ return if context.nil?
160
+
161
+ thread_context_store.save(channel_id: channel, thread_ts: thread_ts, context: context)
162
+ end
163
+
164
+ # Post a message into the assistant thread
165
+ #
166
+ # @param message [String, Hash] Text or chat.postMessage options
167
+ # @return [Hash] The Slack API response
168
+ def say(message)
169
+ options = message.is_a?(Hash) ? message : { text: message }
170
+ client.chat_postMessage(options.merge(channel: channel, thread_ts: thread_ts))
171
+ end
172
+
173
+ # Set the status line shown while the assistant works
174
+ #
175
+ # @param status [String] Status text, for example 'is thinking...'
176
+ # @return [Hash] The Slack API response
177
+ def set_status(status)
178
+ client.assistant_threads_setStatus(thread_target.merge(status: status))
179
+ end
180
+
181
+ # Set the title of the assistant thread
182
+ #
183
+ # @param title [String] The thread title
184
+ # @return [Hash] The Slack API response
185
+ def set_title(title)
186
+ client.assistant_threads_setTitle(thread_target.merge(title: title))
187
+ end
188
+
189
+ # Set the suggested prompts shown to the user
190
+ #
191
+ # Plain strings become prompts with the same title and message.
192
+ #
193
+ # @param prompts [Array<String, Hash>] Up to four prompts
194
+ # @param title [String, nil] Optional heading above the prompts
195
+ # @return [Hash] The Slack API response
196
+ def set_suggested_prompts(prompts, title: nil)
197
+ options = thread_target.merge(prompts: normalize_prompts(prompts))
198
+ options[:title] = title if title
199
+ client.assistant_threads_setSuggestedPrompts(options)
200
+ end
201
+
202
+ private
203
+
204
+ # Return the configured thread context store
205
+ #
206
+ # @return [Object]
207
+ def thread_context_store
208
+ BoltRb.configuration.assistant_thread_context_store
209
+ end
210
+
211
+ # Build the channel and thread arguments for assistant API calls
212
+ #
213
+ # @return [Hash]
214
+ def thread_target
215
+ { channel_id: channel, thread_ts: thread_ts }
216
+ end
217
+
218
+ # Convert prompt strings into title and message hashes
219
+ #
220
+ # @param prompts [Array<String, Hash>]
221
+ # @return [Array<Hash>]
222
+ def normalize_prompts(prompts)
223
+ prompts.map do |prompt|
224
+ prompt.is_a?(Hash) ? prompt : { title: prompt, message: prompt }
225
+ end
226
+ end
227
+ end
228
+ end
229
+
230
+ # Top-level alias for convenience
231
+ AssistantHandler = Handlers::AssistantHandler
232
+ end
@@ -129,8 +129,72 @@ module BoltRb
129
129
  payload
130
130
  end
131
131
 
132
+ # Creates an assistant_thread_started event payload
133
+ #
134
+ # @param user [String] The user ID (default: 'U123TEST')
135
+ # @param channel [String] The DM channel ID (default: 'D456TEST')
136
+ # @param thread_ts [String, nil] The thread timestamp (auto-generated if nil)
137
+ # @param context [Hash] The thread context (default: a channel and team)
138
+ # @return [Hash] The assistant_thread_started payload
139
+ def assistant_thread_started(user: 'U123TEST', channel: 'D456TEST', thread_ts: nil, context: nil)
140
+ assistant_thread_event('assistant_thread_started', user, channel, thread_ts, context)
141
+ end
142
+
143
+ # Creates an assistant_thread_context_changed event payload
144
+ #
145
+ # @param user [String] The user ID (default: 'U123TEST')
146
+ # @param channel [String] The DM channel ID (default: 'D456TEST')
147
+ # @param thread_ts [String, nil] The thread timestamp (auto-generated if nil)
148
+ # @param context [Hash] The new thread context (default: a channel and team)
149
+ # @return [Hash] The assistant_thread_context_changed payload
150
+ def assistant_thread_context_changed(user: 'U123TEST', channel: 'D456TEST', thread_ts: nil,
151
+ context: nil)
152
+ assistant_thread_event('assistant_thread_context_changed', user, channel, thread_ts, context)
153
+ end
154
+
155
+ # Creates a user message inside an assistant thread
156
+ #
157
+ # @param text [String] The message text
158
+ # @param thread_ts [String] The assistant thread timestamp
159
+ # @param user [String] The user ID (default: 'U123TEST')
160
+ # @param channel [String] The DM channel ID (default: 'D456TEST')
161
+ # @return [Hash] The threaded direct message payload
162
+ def assistant_message(text:, thread_ts:, user: 'U123TEST', channel: 'D456TEST')
163
+ {
164
+ 'type' => 'event_callback',
165
+ 'event' => {
166
+ 'type' => 'message',
167
+ 'channel_type' => 'im',
168
+ 'text' => text,
169
+ 'user' => user,
170
+ 'channel' => channel,
171
+ 'ts' => generate_ts,
172
+ 'thread_ts' => thread_ts
173
+ }
174
+ }
175
+ end
176
+
132
177
  private
133
178
 
179
+ # Builds an assistant thread event payload
180
+ #
181
+ # @return [Hash]
182
+ def assistant_thread_event(type, user, channel, thread_ts, context)
183
+ {
184
+ 'type' => 'event_callback',
185
+ 'event' => {
186
+ 'type' => type,
187
+ 'assistant_thread' => {
188
+ 'user_id' => user,
189
+ 'channel_id' => channel,
190
+ 'thread_ts' => thread_ts || generate_ts,
191
+ 'context' => context || { 'channel_id' => 'C456TEST', 'team_id' => 'T123TEST' }
192
+ },
193
+ 'event_ts' => generate_ts
194
+ }
195
+ }
196
+ end
197
+
134
198
  # Generates a fake Slack timestamp
135
199
  #
136
200
  # @return [String] A timestamp in Slack's format (epoch.random)
@@ -44,6 +44,9 @@ module BoltRb
44
44
  allow(client).to receive(:chat_update).and_return({ 'ok' => true })
45
45
  allow(client).to receive(:views_open).and_return({ 'ok' => true })
46
46
  allow(client).to receive(:views_update).and_return({ 'ok' => true })
47
+ allow(client).to receive(:assistant_threads_setStatus).and_return({ 'ok' => true })
48
+ allow(client).to receive(:assistant_threads_setTitle).and_return({ 'ok' => true })
49
+ allow(client).to receive(:assistant_threads_setSuggestedPrompts).and_return({ 'ok' => true })
47
50
  client
48
51
  end
49
52
 
@@ -1,4 +1,4 @@
1
1
  # lib/bolt_rb/version.rb
2
2
  module BoltRb
3
- VERSION = '0.3.1'
3
+ VERSION = '0.5.0'
4
4
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoltRb
4
+ # Fixed-size pool of threads that run handler jobs.
5
+ #
6
+ # The Socket Mode client reads frames on one thread. Handlers that
7
+ # wait on the network must not run on that thread, or pings and later
8
+ # events stall behind them. The App posts each event to this pool
9
+ # and the socket thread returns to reading at once.
10
+ #
11
+ # @example
12
+ # pool = BoltRb::WorkerPool.new(size: 4, logger: BoltRb.logger)
13
+ # pool.start
14
+ # pool.post { do_slow_work }
15
+ # pool.shutdown
16
+ class WorkerPool
17
+ # @return [Integer] Number of worker threads
18
+ attr_reader :size
19
+
20
+ # Creates a new pool. Call #start to spawn the threads.
21
+ #
22
+ # @param size [Integer] Number of worker threads
23
+ # @param logger [Logger] Logger for job errors
24
+ # @param shutdown_timeout [Numeric] Seconds to wait for each worker on shutdown
25
+ def initialize(size:, logger:, shutdown_timeout: 30)
26
+ @size = size
27
+ @logger = logger
28
+ @shutdown_timeout = shutdown_timeout
29
+ @queue = nil
30
+ @threads = []
31
+ @running = false
32
+ end
33
+
34
+ # Spawns the worker threads
35
+ #
36
+ # @return [void]
37
+ def start
38
+ return if @running
39
+
40
+ @queue = Queue.new
41
+ @running = true
42
+ @threads = Array.new(size) { |index| spawn_worker(index) }
43
+ end
44
+
45
+ # Queues a job for a worker thread
46
+ #
47
+ # If the pool is not running, the job runs on the calling thread.
48
+ #
49
+ # @yield The job to run
50
+ # @return [void]
51
+ def post(&job)
52
+ if @running
53
+ @queue << job
54
+ else
55
+ run_job(job)
56
+ end
57
+ end
58
+
59
+ # Stops accepting jobs, finishes queued jobs, and joins the workers
60
+ #
61
+ # Workers that do not finish inside the shutdown timeout are left to
62
+ # exit on their own. The pool reports not running either way.
63
+ #
64
+ # @return [void]
65
+ def shutdown
66
+ return unless @running
67
+
68
+ @running = false
69
+ @queue.close
70
+ @threads.each do |thread|
71
+ next if thread.join(@shutdown_timeout)
72
+
73
+ @logger.warn "[WorkerPool] #{thread.name} did not finish within #{@shutdown_timeout}s"
74
+ end
75
+ @threads = []
76
+ end
77
+
78
+ # @return [Boolean] Whether the pool accepts jobs
79
+ def running?
80
+ @running
81
+ end
82
+
83
+ # @return [Integer] Number of jobs waiting for a worker
84
+ def queue_size
85
+ @queue ? @queue.size : 0
86
+ end
87
+
88
+ private
89
+
90
+ # Creates one worker thread that drains the queue until it closes
91
+ #
92
+ # @param index [Integer] Worker number, used in the thread name
93
+ # @return [Thread]
94
+ def spawn_worker(index)
95
+ Thread.new do
96
+ Thread.current.name = "bolt-rb-worker-#{index}"
97
+ while (job = @queue.pop)
98
+ run_job(job)
99
+ end
100
+ end
101
+ end
102
+
103
+ # Runs one job and logs any error it raises
104
+ #
105
+ # @param job [Proc]
106
+ # @return [void]
107
+ def run_job(job)
108
+ job.call
109
+ rescue StandardError => e
110
+ @logger.error "[WorkerPool] Job failed: #{e.class}: #{e.message}"
111
+ @logger.error e.backtrace.first(5).join("\n") if e.backtrace
112
+ end
113
+ end
114
+ end
data/lib/bolt_rb.rb CHANGED
@@ -62,9 +62,11 @@ require_relative 'bolt_rb/version'
62
62
  require_relative 'bolt_rb/middleware/base'
63
63
  require_relative 'bolt_rb/middleware/chain'
64
64
  require_relative 'bolt_rb/middleware/logging'
65
+ require_relative 'bolt_rb/assistant/memory_thread_context_store'
65
66
  require_relative 'bolt_rb/configuration'
66
67
  require_relative 'bolt_rb/context'
67
68
  require_relative 'bolt_rb/router'
69
+ require_relative 'bolt_rb/worker_pool'
68
70
  require_relative 'bolt_rb/handlers/base'
69
71
  require_relative 'bolt_rb/handlers/event_handler'
70
72
  require_relative 'bolt_rb/handlers/command_handler'
@@ -72,6 +74,7 @@ require_relative 'bolt_rb/handlers/action_handler'
72
74
  require_relative 'bolt_rb/handlers/shortcut_handler'
73
75
  require_relative 'bolt_rb/handlers/view_submission_handler'
74
76
  require_relative 'bolt_rb/handlers/view_closed_handler'
77
+ require_relative 'bolt_rb/handlers/assistant_handler'
75
78
  require_relative 'bolt_rb/socket_mode/client'
76
79
  require_relative 'bolt_rb/testing'
77
80
  require_relative 'bolt_rb/app'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bolt_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Whitcraft
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-29 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: slack-ruby-client
@@ -38,20 +38,6 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.9'
41
- - !ruby/object:Gem::Dependency
42
- name: bundler
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '2.0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '2.0'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: rake
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -106,9 +92,11 @@ files:
106
92
  - README.md
107
93
  - lib/bolt_rb.rb
108
94
  - lib/bolt_rb/app.rb
95
+ - lib/bolt_rb/assistant/memory_thread_context_store.rb
109
96
  - lib/bolt_rb/configuration.rb
110
97
  - lib/bolt_rb/context.rb
111
98
  - lib/bolt_rb/handlers/action_handler.rb
99
+ - lib/bolt_rb/handlers/assistant_handler.rb
112
100
  - lib/bolt_rb/handlers/base.rb
113
101
  - lib/bolt_rb/handlers/command_handler.rb
114
102
  - lib/bolt_rb/handlers/event_handler.rb
@@ -124,6 +112,7 @@ files:
124
112
  - lib/bolt_rb/testing/payload_factory.rb
125
113
  - lib/bolt_rb/testing/rspec_helpers.rb
126
114
  - lib/bolt_rb/version.rb
115
+ - lib/bolt_rb/worker_pool.rb
127
116
  homepage: https://github.com/h2ik/bolt_rb
128
117
  licenses:
129
118
  - MIT