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 +4 -4
- data/README.md +70 -0
- data/lib/bolt_rb/app.rb +20 -8
- data/lib/bolt_rb/assistant/memory_thread_context_store.rb +61 -0
- data/lib/bolt_rb/configuration.rb +17 -0
- data/lib/bolt_rb/handlers/assistant_handler.rb +232 -0
- data/lib/bolt_rb/testing/payload_factory.rb +64 -0
- data/lib/bolt_rb/testing/rspec_helpers.rb +3 -0
- data/lib/bolt_rb/version.rb +1 -1
- data/lib/bolt_rb/worker_pool.rb +114 -0
- data/lib/bolt_rb.rb +3 -0
- metadata +5 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4a88c6dfe625ec417db209cf02d879226ca184d5e4dbbc7c69525e8a2ed1b11
|
|
4
|
+
data.tar.gz: 55e054a4575b2f4831d00b9e15154e3e140a6dc13f433e756cc06d5308dd0780
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
132
|
-
# to the
|
|
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
|
-
|
|
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
|
|
data/lib/bolt_rb/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|